Make message clear() O(1) with presence-guarded getters - #11
Conversation
parseFrom() begins with clear(), which physically reset every field: 13-23% of deserialization time in CPU profiles (a generated MessageMetadata.clear() executed ~60 field stores per parse). clear() now only resets presence state (bitfields, oneof cases, repeated counts, _cachedSize, _parsedBuffer): - Presence bits are tracked for all non-repeated, non-oneof fields; proto3 implicit-presence fields get an internal bit (no has() method) marking "written since last clear". - Getters return the field default while presence is unset, so stale values from previous parse/set cycles are unobservable. - String/bytes fields invalidate their cached decoded value in parse() instead of clear(), paying the cost only for fields present on the wire. - equals()/hashCode() mask bitfields to explicit-presence bits so that proto3 "set to default" remains indistinguishable from "never set". - copyFrom() guards implicit-presence fields on the source's presence bit (protobuf merge semantics). - Repeated message and map clear() reset only the element count; the parse path adds elements without clearing (parseFrom clears the child itself), while the public addX() clears reused pooled instances. This also fixes two latent bugs, now covered by InstanceReuseTest: - clearX() on a message field cleared the presence bit before the has()-guarded child clear, leaving a stale child reachable via getX(). - copyFrom() overwrote target fields with unset implicit-presence source values. Deserialization throughput (JMH, Apple M-series): AddressBook 19.4 -> 26.3 ops/us (+35%) Pulsar MessageMetadata 14.3 -> 17.4 ops/us (+22%) Simple + readString 38.6 -> 51.3 ops/us (+33%) Pulsar BaseCommand 33.2 -> 34.2 ops/us (+3%)
There was a problem hiding this comment.
Pull request overview
This PR updates the Java code generator to make Message.clear() effectively O(1) by resetting only presence/state while ensuring stale pooled values remain unobservable via presence-guarded read paths. It also adds a comprehensive regression test suite to validate instance reuse safety across parse/clear cycles.
Changes:
- Introduces internal presence bits for proto3 implicit-presence scalars and updates generated getters/materialize paths to return defaults when absent.
- Moves per-field invalidation of cached decoded
String/ByteBufreferences into the parse path and reduces per-element clearing for repeated/map fields. - Adds
InstanceReuseTestto cover reuse hazards (wide→narrow reparses, stale lazy-decode caches, repeated/map pooling, oneof switching, equality semantics, copy/merge behavior).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/src/test/java/io/streamnative/lightproto/tests/InstanceReuseTest.java | New regression tests validating pooled instance reuse correctness under the new clear/presence model. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java | Generates presence masks for all non-repeated/non-oneof fields and introduces presenceCondition() used by guarded getters/materialize. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java | Sets internal presence bits during parse, masks bitfields for equals/hashCode proto3 semantics, and changes implicit-presence copyFrom() behavior. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java | Adds presence-guarded getters; adjusts equals/hashCode to avoid stale values for implicit-presence numerics. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java | Adds presence-guarded getter and stale-safe equals/hashCode for implicit presence. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java | Adds presence-guarded getter and stale-safe equals/hashCode for implicit presence enums. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java | Guards getter/materialize by presence; invalidates cached decoded string on parse. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java | Guards getters/materialize by presence; invalidates cached buffer reference on parse. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java | Stops per-element clear; drops stale decoded string in pooled holder on parse. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java | Stops per-element clear; drops stale buffer reference in pooled holder on parse. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java | Avoids double-clearing pooled children by using an internal add-for-parse path; ensures public add clears before handing out. |
| code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java | Resets pooled message-value slots during parse and removes per-entry clear in map clear(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (f.isRepeated()) { | ||
| f.copy(w); | ||
| } else if (f.field.hasImplicitPresence()) { | ||
| // Always copy for proto3 implicit presence — no has() to check | ||
| // No public has() — guard on the other message's internal presence bit, | ||
| // since its field value may be stale when the bit is unset. | ||
| w.format(" if ((_other._bitField%d & %s) != 0) {\n", f.bitFieldIndex(), f.fieldMask()); | ||
| f.copy(w); | ||
| w.format(" }\n"); |
There was a problem hiding this comment.
Good catch — fixed in e4a02aa. The copy condition for implicit-presence fields now also requires the source value to be non-default (same expression as the serialization condition), matching protobuf-java's mergeFrom behavior. Added testCopyFromTreatsSetToDefaultAsUnset to pin it down.
Review feedback: copyFrom() guarded implicit-presence fields only on the source's internal presence bit, so a field explicitly set to its default (e.g. setIntField(0)) would overwrite a non-default target value. proto3 merge semantics treat default == unset (protobuf-java mergeFrom checks `other.getX() != 0`), and the field would not survive a serialize/parse roundtrip either. The copy condition now also requires the source value to be non-default, matching the serialization condition.
Motivation
Messages are designed to be pooled and reused, and
parseFrom()begins withclear()— which physically reset every field back to its default. In CPU profiles of deserialization this is 13–23% of total time: a generatedMessageMetadata.clear()executed ~60 field stores before every parse, even when the incoming message only sets a handful of fields.Changes
clear()now only resets presence state — bitfields, oneof cases, repeated counts,_cachedSize,_parsedBuffer— and stale field values are made unobservable instead of being reset:has()method is exposed) that marks "written since last clear".MessageMetadata.clear()drops from ~60 stores to 6.parse(): the cached decodedString/ByteBufreference is dropped when the field is present on the wire, so the cost is paid per present field instead of per declared field.equals()/hashCode()mask bitfields to explicit-presence bits (_PRESENCE_CMP_MASK), preserving proto3 semantics: "explicitly set to the default" equals "never set".copyFrom()guards implicit-presence fields on the source's presence bit, matching protobuf merge semantics.clear()reset only the count. The parse path adds elements via an internal_addXForParse()that skips clearing (the child'sparseFrom()clears it anyway — previously each reused child was cleared twice per cycle), while the publicaddX()clears reused pooled instances before handing them out.Latent bugs fixed (caught by the new
InstanceReuseTest, verified failing on the previous generator)clearX()on a message field cleared the presence bit before thehas()-guarded child clear, so the child was never cleared andgetX()returned stale data.copyFrom()copied implicit-presence fields unconditionally, overwriting target fields with unset (default) source values.Results
JMH deserialization throughput (Apple M-series, higher is better):
Zero steady-state allocation on the parse path is unchanged (verified with
-prof gc, ≈10⁻⁴ B/op).One tradeoff to be aware of: since values are no longer physically reset, a cleared pooled instance can keep a previously-set
String/ByteBufreferenced until the next set/parse of that field. This is never observable through the API (every read path is presence-guarded), only a memory-retention consideration comparable to the existing_parsedBufferbehavior.Verification
InstanceReuseTestcovers the reuse hazards directly: wide→narrow reparse, stale decoded strings, pooled repeated/map element reuse, oneof case switching,materialize()on absent fields, proto3 set-to-default vs unset equality, andcopyFrommerge semantics.