Skip to content

Iterate presence bits in clear(), writeTo() and getSerializedSize() for union-like messages - #13

Merged
merlimat merged 2 commits into
streamnative:masterfrom
merlimat:bitdriven-clear
Jul 30, 2026
Merged

Iterate presence bits in clear(), writeTo() and getSerializedSize() for union-like messages#13
merlimat merged 2 commits into
streamnative:masterfrom
merlimat:bitdriven-clear

Conversation

@merlimat

@merlimat merlimat commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Union-like messages declare many singular message fields but only ever set one or two. Pulsar's BaseCommand has 52 singular fields, and three hot methods walked a guard per declared field on every cycle:

  • clear() (runs at the start of every parseFrom() and every fill): 60 sequential if (hasX()) x.clear() guards
  • _writeTo(): a has() guard per field
  • getSerializedSize(): a has() guard per field

Fresh flame graphs (post-#11/#12) surfaced the clear() guard chain at ~5–7% of self time; removing all three walks measured far larger than the profile share — dozens of correlated branches per call cost more in branch-prediction pressure than their sample counts suggest.

Changes

clear() (all message shapes with ≥ 4 singular message fields): iterate the set bits of _bitFieldN & _MSG_FIELDS_MASKN with an Integer.numberOfTrailingZeros loop and a dense switch, clearing only the children actually present — O(set fields) instead of O(declared fields). The presence bit implies the child was allocated, the same invariant the guarded form relied on.

_writeTo() and getSerializedSize() (messages where every field is singular and non-oneof, ≥ 8 fields): the same set-bit traversal. Set bits ascend, so fields are emitted in declaration order and the output bytes are identical to the guard walk — verified by the round-trip tests against protobuf-java. proto3 implicit-presence fields keep their non-default check inside the case; required fields always have their bit set once checkRequiredFields() has passed. The two methods share the traversal emitter, so they agree exactly on which fields contribute (writeTo fills an array sized by getSerializedSize).

Messages with repeated, map or oneof fields keep the guard walk for serialization (those fields carry no presence bits, so ordering across them could not be preserved), and small messages generate byte-identical code to before — MessageMetadata, Frame etc. are unaffected and were verified flat as benchmark controls.

Results

JMH, Apple M-series, ops/µs, same-session baselines, 2–3 forks:

Benchmark before after Δ
BaseCommand serialize 25.4 43.4 +71%
BaseCommand deserialize 36.1 52.3 +45%
MessageMetadata serialize 14.5 14.2 unchanged (control)
MessageMetadata deserialize 16.7 17.1 unchanged (control)

(Per-commit split: the clear() change contributes +17% ser / +33% deser; the writeTo/size change adds a further +50% ser.)

BaseCommand vs protobuf-java: serialize 1.5× → 2.6×, deserialize 2.8× → 4.1×.

Verification

Full test suite green — including byte-identical round-trips against protobuf-java (which pins the serialization order) and the instance-reuse suite from #11 exercising clear/reparse cycles on pooled instances.

Union-like messages declare many singular message fields but only ever
set one or two: Pulsar's BaseCommand has ~60, and its clear() walked a
sequential `if (hasX()) x.clear()` guard for every one of them on every
parse and every fill cycle. Flame graphs showed the guard chain as ~5-7%
of self time, and removing it measured far larger — sixty correlated
branches per clear() cost more in branch-prediction pressure than their
profile share suggested.

For messages with at least 4 singular message fields, clear() now
iterates the set bits of (_bitFieldN & _MSG_FIELDS_MASKN) with a
numberOfTrailingZeros loop and a dense switch on the field index,
clearing only the children that are actually present: O(set message
fields) instead of O(declared fields). Messages below the threshold
keep the plain per-field guards and generate byte-identical code.

Throughput (JMH, Apple M-series, ops/us, 2-3 forks):

  BaseCommand deserialize   36.1 -> 48.1  (+33%)
  BaseCommand serialize     25.4 -> 29.7  (+17%)
  MessageMetadata ser/deser unchanged (no singular message fields)
Copilot AI review requested due to automatic review settings July 30, 2026 05:59

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 generated clear() implementations for “union-like” messages that declare many singular (non-oneof) message fields but usually set only a few, by emitting a bit-driven loop that clears only the message fields whose presence bits are set.

Changes:

  • Add a generator heuristic (BIT_DRIVEN_CLEAR_THRESHOLD = 4) to choose between per-field if (hasX()) x.clear() and a set-bit iteration strategy.
  • Emit bit-driven clear() code that iterates (_bitFieldN & _MSG_FIELDS_MASKN) and clears only present singular message fields via a switch on the bit index.
  • Generate per-bitfield-word _MSG_FIELDS_MASKN constants to restrict the set-bit loop to singular message fields only.

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

Same principle as the clear() change: union-like messages set only one
or two of their many fields, yet serialization and size computation
walked every field's has() guard. For messages where all fields are
singular and non-oneof (so every field carries a presence bit),
_writeTo() and getSerializedSize() now iterate the set bits per word.
Set bits ascend, so fields are emitted in declaration order and the
output bytes are identical to the guard walk; proto3 implicit-presence
fields keep their non-default check inside the case, and required
fields always have their bit set once checkRequiredFields() has passed.

Messages with repeated, map or oneof fields keep the guard walk
(ordering across unbitted fields could not be preserved otherwise), as
do messages below an 8-field threshold.

Throughput (JMH, Apple M-series, ops/us, 2 forks, same-session
baseline on top of the bit-driven clear() commit):

  BaseCommand serialize     28.8 -> 43.4  (+50%)
  BaseCommand deserialize   unchanged (52.3 +- 1.9)
  MessageMetadata ser/deser unchanged (controls)
Copilot AI review requested due to automatic review settings July 30, 2026 06:11
@merlimat merlimat changed the title Clear only present message fields via set-bit iteration Iterate presence bits in clear(), writeTo() and getSerializedSize() for union-like messages Jul 30, 2026
@merlimat
merlimat merged commit ee34fd7 into streamnative:master Jul 30, 2026
1 check passed

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 1 out of 1 changed files in this pull request and generated no new comments.

@merlimat
merlimat deleted the bitdriven-clear branch July 30, 2026 06:16
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