Skip to content

Read 64-bit varints through unchecked Netty accessors - #14

Merged
merlimat merged 6 commits into
streamnative:masterfrom
merlimat:unchecked-netty-reads
Jul 30, 2026
Merged

Read 64-bit varints through unchecked Netty accessors#14
merlimat merged 6 commits into
streamnative:masterfrom
merlimat:unchecked-netty-reads

Conversation

@merlimat

Copy link
Copy Markdown
Collaborator

Motivation

After #11/#13, the largest remaining deserialize cost on varint64-heavy messages is Netty's per-byte read overhead: every readByte() pays checkReadableBytes0() (bounds + accessibility) plus a readerIndex field store. For a message like Pulsar's MessageMetadata (sequence ids, timestamps, ledger/entry ids), that is a large fraction of the parse profile. Disabling the checks globally via -Dio.netty.buffer.checkBounds=false recovers +8-17% deserialize, but it is a process-wide tradeoff; this PR recovers the same class of win in code, only where it pays.

Changes

  • New per-package generated helper in the io.netty.buffer package, giving access to AbstractByteBuf's protected _getByte() and its package-private readerIndex field. readVarInt64Unchecked() decodes a whole varint with no per-byte checks and a single readerIndex store: 1-2 byte values decode in a small always-inlined front (the full nested chain is ~371 bytecodes, over C2's FreqInlineSize, so it is never inlined — routing short varints through it costs a call per read and measured as a net loss); 3+ byte values fall into the chain, whose decode amortizes the call.
  • LightProtoCodec.readVarInt64() routes AbstractByteBuf instances (heap, pooled/unpooled direct) to the unchecked reader and everything else to the existing checked chain, which moved to its own method so the dispatch always inlines. Only 64-bit varints take this path — tags, lengths and 32-bit varints measured faster on the canonical checked readByte() pattern.
  • Since an unchecked read of a truncated varint64 no longer throws mid-field, parseFrom() restores the throw-on-truncated contract with one post-loop readerIndex > endIdx check — emitted only for messages that parse 64-bit varint fields (including via map key/value types). Every other reader is bounds-checked, so all other messages keep parseFrom() byte-identical to the fully checked version (an unconditionally emitted check measurably perturbed codegen of small messages).

Benchmark results

Interleaved A/B against current master (alternating single-fork runs x6, JDK 26, Apple M-series; means of 6 rounds x 5 iterations):

Deserialize master this PR delta
MessageMetadata 17.22 ops/us 23.07 ops/us +34%
BaseCommand 45.22 ops/us 47.44 ops/us +5%
AddressBook 26.61 ops/us 26.58 ops/us -
Frame (Simple) 87.71 ops/us 88.20 ops/us -
Frame + readString 51.38 ops/us 51.20 ops/us -

MessageMetadata deserialize is now ~5.6x protobuf-java (23.1 vs 4.1 ops/us).

Safety on malformed input

  • Well-formed input, any length: behavior unchanged (varints are self-terminating; reads stay within the message).
  • Truncated input: a truncated 64-bit varint field can read up to 9 bytes past the message limit within the buffer before the post-parse check throws IndexOutOfBoundsException (TruncatedInputTest covers truncation at every wire type, on heap and pooled-direct buffers). Heap buffers degrade to an ArrayIndexOutOfBoundsException at the array edge at worst; pooled direct buffers stay inside the arena chunk.
  • Residual corner: a truncated trailing varint64 in an exactly-sized unpooled unsafe direct buffer can read past the allocation (the same exposure -Dio.netty.buffer.checkBounds=false has globally, here narrowed to one read type on malformed input).

Caveat

The helper class makes the generated code share the io.netty.buffer package: fine on the classpath (Pulsar's deployment model), but a split package under the JPMS module path.

Commit history preserves the experiment sequence (gated -> gateless -> varint64-only -> inlining/conditional-check fixes); squash-merge collapses it.

merlimat added 5 commits July 30, 2026 12:21
…t merge

Measured and rejected (2026-07-30): a generated helper class in the
io.netty.buffer package reaches AbstractByteBuf's protected _getByte /
_getIntLE / _getLongLE and the readerIndex field directly, decoding a
whole varint with zero per-byte checks and a single index store. The
codec gates each read on `instanceof AbstractByteBuf && readable >= 10`
and falls back to the checked chain near the buffer end.

Despite removing every check, ALL deserialize benchmarks regressed
(same-session baseline, 2 forks, tight bars):

  Simple            87.0 -> 59.1  (-32%)
  Simple+readString 51.3 -> 30.7  (-40%)
  BaseCommand       46.8 -> 32.3  (-31%)
  AddressBook       27.3 -> 20.2  (-26%)
  MessageMetadata   17.4 -> 15.9   (-8%)

Interpretation: C2 optimizes the canonical checkReadableBytes0()/
readByte() pattern to near-free (the Netty kill-switch flags showed the
checks cost only 8-17% when removed globally with no gate); the
two-path gate plus a hand-built chain defeats that. Preserved for
reference only — see the perf-plan gist, section 4.
Removing the `readableBytesFast(a) >= N` gate per read flipped the
experiment from a uniform regression to a real win: the gate branch was
the entire cost of the previous commit, not the unchecked chain.

Safety is now enforced once per message instead of once per varint:
- A truncated field always consumes past the message limit, so a single
  post-parse `readerIndex > endIdx` check restores the
  throw-on-truncated contract (covered by TruncatedInputTest across
  heap and pooled-direct buffers).
- Overruns are bounded at 9 bytes. Heap buffers fail safely
  (ArrayIndexOutOfBoundsException is an IndexOutOfBoundsException) and
  pooled direct buffers read within their arena chunk. The residual
  exposure is a truncated trailing varint on a tight-capacity unpooled
  unsafe-direct buffer.

Deserialize throughput vs the checked baseline (JMH, Apple M-series,
2-4 forks; later runs on this machine showed degraded/noisy conditions,
so a long-run confirmation matrix is required before merging):

  MessageMetadata   17.4 -> 23.7  (+36%, reproducible across runs)
  Simple            87.0 -> 99.4  (+14%)
  AddressBook       27.3 -> 27.5  (flat)
  BaseCommand       46.8 -> 43.4  (-7%, within fork variance)
  Simple+readString 51.3 -> 48.2  (-6%)
Full 5-fork matrix vs the checked baseline: the unchecked chain is a
large win exactly where multi-byte varints dominate (MessageMetadata
+31%, reproducible across every run and measurement condition) and
appeared to regress 1-byte-dominated shapes. The regression readings
are thermally confounded, however: back-to-back matrix arms on a
throttling laptop penalize later arms, and the hybrid arm "regressed"
readString -17% with a provably byte-identical hot path (no varint64
fields in Frame/Point at all). Decision-grade adjudication of the
checked-path shapes needs an interleaved A/B on a cool machine.

This commit keeps the unchecked chain only in readVarInt64(), where the
win is certain; readVarInt() and the fixed readers stay on the
canonical checked path, which C2 compiles to near-free for the 1-byte
case. The post-parse limit check and TruncatedInputTest cover the
(at most 9-byte) overrun of a truncated varint64.
Interleaved A/B against master showed the varint64-only unchecked path
regressing every benchmark without long varints: PrintInlining revealed
readVarInt64Unchecked (371 bytecodes) and the readVarInt64 wrapper with
the checked chain inline (303 bytecodes) both fail to inline, so 1-2 byte
varints paid a call per read; and the unconditional post-parse truncation
check perturbed codegen of small messages that cannot overrun at all.

- Decode 1-2 byte varint64s in a small always-inlined front, falling into
  the (still non-inlined) nested chain only for 3+ byte values.
- Split the checked fallback out of readVarInt64 so the instanceof gate
  always inlines.
- Emit the post-parse truncation check only for messages that parse
  64-bit varints; all other messages keep parseFrom byte-identical to
  the fully checked version.
readableBytesFast, readVarIntUnchecked and the fixed-int readers were
residue of the earlier gated/gateless experiments; nothing references
them since unchecked reads were restricted to 64-bit varints. Also
update the class javadoc: safety is enforced by the generated
parseFrom(), not by caller-side readability validation.
Copilot AI review requested due to automatic review settings July 30, 2026 12:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes 64-bit varint deserialization by routing AbstractByteBuf reads through a generated io.netty.buffer helper that uses unchecked Netty accessors, and then restoring the truncated-input exception contract via a post-parse bounds check emitted only for messages that parse varint64 fields.

Changes:

  • Add a generated Netty-package accessor (LightProtoByteBufAccess_*) to decode varint64 with unchecked _getByte() and a single readerIndex write.
  • Update LightProtoCodec.readVarInt64() to dispatch AbstractByteBuf to the unchecked decoder while keeping a checked fallback path.
  • Emit a conditional post-parse readerIndex > endIdx check for messages that can parse varint64 (including via map key/value), and add truncation-focused tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/src/test/java/io/streamnative/lightproto/tests/TruncatedInputTest.java Adds coverage for truncation behavior across heap and pooled-direct buffers, plus a tight-capacity direct sanity test.
code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java Routes varint64 reads through unchecked access for AbstractByteBuf, with a checked fallback.
code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoByteBufAccess.java Introduces the Netty-package unchecked varint64 decoder template used for generated accessors.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java Conditionally emits a post-parse truncation check for messages that can parse varint64.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java Marks map fields as using varint64 parsing when key/value types are int64/uint64/sint64.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoGenerator.java Generates the per-package Netty accessor class and rewrites codec references to it.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java Adds usesVarInt64Parse() / isVarInt64Type() helpers used to gate truncation checks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…te load

- Add MapInt64Message and a truncation test proving the post-parse check
  is emitted when only map key/value types route through readVarInt64().
- Correct the helper javadoc overrun bound: at most 9 bytes past the
  message limit, not 10.
- Narrow TruncatedInputTest's javadoc to the buffer types it covers.
- Fail fast with an explicit message if the helper template resource is
  missing from the classpath.
Copilot AI review requested due to automatic review settings July 30, 2026 15:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoGenerator.java:96

  • javaPackages is collected in a plain List, so if multiple input protos share the same javaPackageName, this loop will regenerate/overwrite LightProtoCodec.java and the Netty accessor class multiple times for the same package. It’s harmless but adds unnecessary work and makes outputs depend on duplicate entries. Consider deduplicating while preserving order (e.g., iterate a LinkedHashSet).
        // Include the coded class once per every generated java package
        for (String javaPackage : javaPackages) {
            // Unchecked ByteBuf accessor companion: lives in the io.netty.buffer
            // package (to reach AbstractByteBuf's protected members) and is named
            // after the generated package so that multiple generated packages in
            // one project don't collide.
            String accessClassName = "LightProtoByteBufAccess_" + javaPackage.replace('.', '_');

@merlimat
merlimat merged commit e637150 into streamnative:master Jul 30, 2026
1 check passed
@merlimat
merlimat deleted the unchecked-netty-reads branch July 30, 2026 17:07
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