Skip to content

Assign IonSystemBuilder sub-builder defaults in the constructor - #1162

Merged
NovemberZulu merged 1 commit into
amazon-ion:masterfrom
anuragdy:fix-ionsystembuilder-copy-allocations
Sep 2, 2026
Merged

Assign IonSystemBuilder sub-builder defaults in the constructor#1162
NovemberZulu merged 1 commit into
amazon-ion:masterfrom
anuragdy:fix-ionsystembuilder-copy-allocations

Conversation

@anuragdy

@anuragdy anuragdy commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Issue #, if available: #1161

Description of changes:

IonSystemBuilder assigns its three sub-builder defaults in field initializers. Java
compiles instance field initializers into every constructor (JLS 12.5), so those three
initializers also ran inside the copy constructor — whose body then immediately overwrote
all three fields with the source builder's values. The three default builders were
constructed and discarded on every copy.

The copy constructor is the only path behind copy(), and therefore also behind
mutable(), immutable(), and every withXxx() method.

This change declares the three fields without initializers and assigns the defaults in the
no-argument constructor instead, so the copy constructor does no redundant work. It is a
four-line change plus comments; _Private_IonReaderBuilder already uses this shape for its
lstFactory field.

Measured effectIonSystemBuilderCopyBenchmark, added here (Corretto 17.0.20,
x86-64, -prof gc, 3×1 s warmup / 5×1 s measurement, 1 fork):

benchmark before after
standard().copy() 77.76 ± 0.73 ns/op, 288 B/op 4.93 ± 0.08 ns/op, 32 B/op
standard().withCatalog(c) 80.73 ± 1.30 ns/op, 288 B/op 5.44 ± 0.15 ns/op, 32 B/op
standard().withCatalog(c).build() 336.7 ± 20.1 ns/op, 784 B/op 277.7 ± 92.8 ns/op, 528 B/op

Allocation is exact (gc.alloc.rate.norm reports ± 0.001 B/op on every row): 256 B/op
eliminated on every copy, and the same 256 B on the build path. That is the discarded text
writer builder (72 B), binary writer builder and its catalog (152 B), and reader builder
(32 B); the surviving 32 B is the IonSystemBuilder.Mutable itself. The wall-clock win on
copy() is larger than the byte count suggests because
_Private_IonManagedBinaryWriterBuilder declares ten volatile fields, so constructing the
discarded binary writer builder emits a series of memory fences. (The build() timing is
noisy; the allocation delta is the reliable signal there.)

A production CPU profile of a large internal service attributes 0.66% of total service CPU
to IonSystemBuilder.copy.

Why the constructor and not static final constants. Hoisting the three defaults into
private static final fields looks cleaner but is a silent trap: STANDARD is initialized
at line 75, textually before where the constants would be declared, so under JLS 12.4.2
it would capture null for all three and build() would NPE. JLS 8.3.3's
illegal-forward-reference rule only covers simple-name uses, so javac accepts it without a
warning.

Behavior. Unchanged.

  • The no-argument constructor compiles to identical bytecode before and after (verified with
    javap -p -c), so standard() still starts with the same three default sub-builders:
    ASCII text writer, standard binary writer, standard reader. The copy constructor drops
    from 74 to 50 bytes of bytecode and from three invokestatic calls to none.
  • Copies still share those sub-builder instances by reference, exactly as before — the
    pre-change copies also shared them, because the initializers' results were overwritten.
  • Verified equivalent before/after: sub-builder class identities, text writer charset,
    emitted binary and text bytes, DOM round-trips, reference sharing across
    copy()/mutable()/immutable() chains, and UnsupportedOperationException on mutating
    an immutable builder.
  • Binary compatible: no signature, modifier, or field-type changes.

Tests. The two new tests, plus three new assertions in testCopy, are regression guards
rather than reproductions — the
defect was wasted work, not wrong results, so they pass before this change as well. They
pin the behavior the fix must not break: standard() exposes non-null defaults with the
ASCII charset, copy() propagates all three sub-builders by reference, and a
copy().mutable().copy().immutable().mutable() chain still ends up sharing standard()'s
instances.

Follow-up, not addressed here. _Private_IonTextWriterBuilder.build(...) constructs a
complete throwaway IonSystem per text writer via fillDefaults(), and
_Private_IonBinaryWriterBuilder calls IonSystemBuilder.standard().build() per binary
writer. That is a larger source of the same profile frame and worth a separate look; happy
to open an issue for it.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Java compiles instance field initializers into every constructor, so the
three sub-builder initializers on IonSystemBuilder also ran inside the
copy constructor, whose body then immediately overwrote all three fields.
Every copy() -- and therefore mutable(), immutable(), and each withXxx()
-- allocated a full set of default text writer, binary writer, and reader
builders only to discard them.

Moving the defaults into the no-argument constructor leaves standard()
byte-for-byte unchanged while making the copy path allocate nothing
beyond the builder itself: copy() drops from 288 to 32 bytes per call
and from ~78 to ~5 ns on the benchmark added here.

No behavior change: standard() still starts with the same three default
sub-builders, and copies still share them by reference exactly as before.
Tests added to lock that down.
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.00%. Comparing base (3c1b6b1) to head (5a9227e).
⚠️ Report is 154 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1162      +/-   ##
============================================
+ Coverage     67.23%   68.00%   +0.77%     
- Complexity     5484     5676     +192     
============================================
  Files           159      160       +1     
  Lines         23025    23378     +353     
  Branches       4126     4204      +78     
============================================
+ Hits          15481    15899     +418     
+ Misses         6262     6173      -89     
- Partials       1282     1306      +24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jobarr-amzn jobarr-amzn left a comment

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.

Thanks! This is a solid improvement. Did you happen to notice the same class of error elsewhere?

@NovemberZulu
NovemberZulu merged commit 63793a7 into amazon-ion:master Sep 2, 2026
30 of 38 checks passed
@anuragdy

anuragdy commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! This is a solid improvement. Did you happen to notice the same class of error elsewhere?

A few more applications with related workload see this hotspot.

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