Skip to content

Skip Conversions when the value already fits (plan item 3.7) - #4412

Merged
evanchooly merged 2 commits into
masterfrom
skip-conversions
Oct 26, 2026
Merged

evanchooly merged 2 commits into
masterfrom
skip-conversions

Conversation

@evanchooly

@evanchooly evanchooly commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Plan item 3.7 (review B8).

The problem

Every decoded property goes through Conversions.convert, even when the value already has the right type. The early return, fromType.equals(target), misses the common cases: an Integer for an int field, an ArrayList for a List, a HashMap for a Map. Each miss then ran computeIfAbsent(fromType, …), which also inserted an empty ConcurrentHashMap for every new value class. Then .get(target) came back empty and the value was returned unchanged.

When a stored value doesn't match the codec (schema drift), the decoder's fallback path converted the value, and the creator converted it again even though it already fit.

Change

  • Fast path. PropertyModel.setValue and ConstructorCreator.set now set the value directly when it's already an instance of the target type, with primitives treated as their wrappers. Only values that need converting reach Conversions.convert.
  • get instead of computeIfAbsent in Conversions.convert. An unregistered source type is no longer added to the map.
  • One conversion on the fallback path. EntityDecoder still converts the fallback value to the property's type, and the creator's fast path now skips the second conversion because the value fits. (The first version of this PR dropped the decoder's conversion instead; see the review thread for why that was wrong.)
  • Conversions.boxedType(Class), a small @MorphiaInternal helper (MethodType.methodType(t).wrap()), shared by both call sites.

Tests

  • TestConversions: boxing; registered conversions still apply ("42" → int, 42.0 → long, "c" → Character); values without a conversion pass through; null → 0 for primitives.
  • The harness fixtures already cover drifted fields (the fallback path), constructor-built entities and records under both mappers, and are unchanged.
  • TestDriftedValues: a drifted value reaching a constructor parameter whose type differs from the property's (Integer → String property → Character parameter) still converts through the property type.
  • The core and Kotlin suites pass with -Dmorphia.mapper=critter and with reflection: 1498 + 5 tests, 0 failures.

JMH

Expect a small decode-only gain. The review estimated ~10–20 ns per primitive- or interface-typed property, with SIMPLE having 4 such properties.

decode, master (f41533b8735) and this branch alternating, three rounds each

variant benchmark model master ns/op (3 runs) 3.7 ns/op (3 runs) Δ median
reflection CodecBenchmark.decode COLLECTIONS 8,087, 8,113, 8,326 8,057, 8,167, 8,287 +0.7%
reflection CodecBenchmark.decode NESTED 2,710, 2,742, 2,785 2,618, 2,645, 2,664 -3.5%
reflection CodecBenchmark.decode POLYMORPHIC 4,980, 5,017, 5,091 4,842, 4,893, 4,928 -2.5%
reflection CodecBenchmark.decode SIMPLE 1,092, 1,126, 1,171 1,010, 1,030, 1,042 -8.5%
reflection MixedCodecBenchmark.decode - 26,760, 26,957, 27,410 26,552, 26,594, 26,689 -1.3%
critter CodecBenchmark.decode COLLECTIONS 8,586, 8,606, 8,654 8,486, 8,649, 8,699 +0.5%
critter CodecBenchmark.decode NESTED 3,063, 3,101, 3,135 3,002, 3,011, 3,028 -2.9%
critter CodecBenchmark.decode POLYMORPHIC 5,396, 5,430, 5,586 5,260, 5,416, 5,439 -0.3%
critter CodecBenchmark.decode SIMPLE 1,126, 1,167, 1,197 1,045, 1,058, 1,072 -9.3%
critter MixedCodecBenchmark.decode - 29,106, 29,181, 29,247 28,039, 28,302, 28,929 -3.0%

Reading it:

  • SIMPLE decodes 8.5–9.3% faster under both mappers, and every branch run beats every master run. ~95 ns over 4 primitive properties is ~24 ns each, at the top of the review's estimate.
  • NESTED is ~3% faster. POLYMORPHIC is faster under reflection; under critter the runs overlap.
  • COLLECTIONS is flat, within noise. Its ~8 µs goes almost entirely to decoding the collections' elements, so saving ~20 ns on each of its few properties doesn't show.
  • Allocation is unchanged. The empty maps computeIfAbsent inserted were per value class, not per value, so after warm-up they never showed up in B/op.
Full CodecBenchmark + MixedCodecBenchmark, one run each (master reflection run was disturbed)

The first master reflection run was noisy: decode error bars reached ±2,264 ns, which inflates the reflection "gains" to 20–35%. That's why the decode rerun above exists. The critter run and all encode rows are clean; encode code is untouched.

reflection

class benchmark model before ns/op after ns/op Δ time before B/op after B/op Δ alloc
CodecBenchmark decode ARRAYS 71,752 ± 1,984 68,881 ± 1,549 -4.0% 74,858 74,858 -0.0%
CodecBenchmark decode COLLECTIONS 8,904 ± 259 8,940 ± 448 +0.4% 16,680 16,704 +0.1%
CodecBenchmark decode CONCRETE_COLLECTIONS 4,474 ± 621 2,913 ± 52 -34.9% 6,144 6,107 -0.6%
CodecBenchmark decode LAZY_REFERENCE 4,888 ± 1,195 3,280 ± 67 -32.9% 7,288 7,309 +0.3%
CodecBenchmark decode LIFECYCLE 1,318 ± 179 1,066 ± 29 -19.1% 2,840 2,840 -0.0%
CodecBenchmark decode NESTED 4,036 ± 460 2,742 ± 85 -32.1% 5,640 5,640 -0.0%
CodecBenchmark decode POLYMORPHIC 7,869 ± 2,264 5,273 ± 190 -33.0% 9,520 9,536 +0.2%
CodecBenchmark decode SIMPLE 1,492 ± 278 1,124 ± 54 -24.7% 2,136 2,136 -0.0%
CodecBenchmark decodeInCursor ARRAYS 71,158 ± 1,833 71,091 ± 1,925 -0.1% 74,546 74,546 +0.0%
CodecBenchmark decodeInCursor COLLECTIONS 10,100 ± 701 8,912 ± 332 -11.8% 16,376 16,392 +0.1%
CodecBenchmark decodeInCursor CONCRETE_COLLECTIONS 3,320 ± 306 3,115 ± 85 -6.2% 5,819 5,840 +0.4%
CodecBenchmark decodeInCursor LAZY_REFERENCE 3,598 ± 81 3,445 ± 148 -4.3% 7,016 6,981 -0.5%
CodecBenchmark decodeInCursor LIFECYCLE 1,204 ± 31 1,151 ± 67 -4.4% 2,557 2,557 +0.0%
CodecBenchmark decodeInCursor NESTED 3,738 ± 943 2,886 ± 120 -22.8% 5,344 5,352 +0.1%
CodecBenchmark decodeInCursor POLYMORPHIC 5,833 ± 118 5,225 ± 221 -10.4% 9,216 9,224 +0.1%
CodecBenchmark decodeInCursor SIMPLE 1,367 ± 77 1,109 ± 44 -18.9% 1,840 1,824 -0.9%
CodecBenchmark encode ARRAYS 103,436 ± 5,576 98,424 ± 1,948 -4.8% 135,587 135,587 -0.0%
CodecBenchmark encode COLLECTIONS 7,966 ± 120 7,539 ± 248 -5.4% 7,336 7,336 -0.0%
CodecBenchmark encode CONCRETE_COLLECTIONS 2,121 ± 54 2,057 ± 33 -3.0% 3,104 3,104 -0.0%
CodecBenchmark encode LAZY_REFERENCE 1,853 ± 67 1,736 ± 25 -6.3% 3,843 3,843 -0.0%
CodecBenchmark encode LIFECYCLE 847 ± 25 813 ± 13 -4.0% 3,021 3,021 -0.0%
CodecBenchmark encode NESTED 2,385 ± 62 2,276 ± 58 -4.5% 1,856 1,856 -0.0%
CodecBenchmark encode POLYMORPHIC 4,227 ± 78 4,065 ± 73 -3.8% 4,200 4,200 -0.0%
CodecBenchmark encode SIMPLE 805 ± 17 794 ± 20 -1.4% 1,216 1,216 -0.0%
MixedCodecBenchmark decode - 32,748 ± 10,096 27,063 ± 768 -17.4% 50,261 50,429 +0.3%
MixedCodecBenchmark encode - 23,095 ± 380 21,914 ± 305 -5.1% 24,657 24,657 +0.0%

critter

class benchmark model before ns/op after ns/op Δ time before B/op after B/op Δ alloc
CodecBenchmark decode ARRAYS 67,317 ± 2,236 68,414 ± 1,112 +1.6% 74,858 74,858 -0.0%
CodecBenchmark decode COLLECTIONS 8,579 ± 219 8,688 ± 123 +1.3% 16,704 16,712 +0.0%
CodecBenchmark decode CONCRETE_COLLECTIONS 2,922 ± 43 2,979 ± 59 +2.0% 6,128 6,128 +0.0%
CodecBenchmark decode LAZY_REFERENCE 3,172 ± 95 3,188 ± 119 +0.5% 7,245 7,245 -0.0%
CodecBenchmark decode LIFECYCLE 1,080 ± 29 1,061 ± 61 -1.8% 2,861 2,813 -1.7%
CodecBenchmark decode NESTED 3,048 ± 152 3,010 ± 147 -1.2% 5,648 5,648 -0.0%
CodecBenchmark decode POLYMORPHIC 5,524 ± 106 5,297 ± 121 -4.1% 9,528 9,552 +0.3%
CodecBenchmark decode SIMPLE 1,150 ± 59 1,030 ± 23 -10.4% 2,136 2,136 -0.0%
CodecBenchmark decodeInCursor ARRAYS 67,781 ± 1,003 66,964 ± 996 -1.2% 74,546 74,546 -0.0%
CodecBenchmark decodeInCursor COLLECTIONS 8,823 ± 165 8,740 ± 212 -0.9% 16,376 16,392 +0.1%
CodecBenchmark decodeInCursor CONCRETE_COLLECTIONS 2,967 ± 94 3,002 ± 121 +1.2% 5,840 5,840 -0.0%
CodecBenchmark decodeInCursor LAZY_REFERENCE 3,216 ± 145 3,307 ± 54 +2.8% 6,947 6,965 +0.3%
CodecBenchmark decodeInCursor LIFECYCLE 1,156 ± 14 1,102 ± 24 -4.7% 2,528 2,523 -0.2%
CodecBenchmark decodeInCursor NESTED 3,034 ± 85 3,150 ± 72 +3.8% 5,344 5,352 +0.1%
CodecBenchmark decodeInCursor POLYMORPHIC 5,525 ± 121 5,426 ± 117 -1.8% 9,216 9,240 +0.3%
CodecBenchmark decodeInCursor SIMPLE 1,082 ± 21 1,104 ± 38 +2.0% 1,824 1,824 +0.0%
CodecBenchmark encode ARRAYS 98,288 ± 1,998 98,250 ± 2,806 -0.0% 144,163 144,163 -0.0%
CodecBenchmark encode COLLECTIONS 7,594 ± 140 7,520 ± 94 -1.0% 7,336 7,336 +0.0%
CodecBenchmark encode CONCRETE_COLLECTIONS 2,013 ± 49 2,020 ± 31 +0.3% 3,104 3,104 +0.0%
CodecBenchmark encode LAZY_REFERENCE 1,750 ± 109 1,671 ± 36 -4.5% 3,885 3,832 -1.4%
CodecBenchmark encode LIFECYCLE 799 ± 21 829 ± 16 +3.7% 3,043 3,064 +0.7%
CodecBenchmark encode NESTED 2,307 ± 39 2,268 ± 34 -1.7% 1,856 1,856 -0.0%
CodecBenchmark encode POLYMORPHIC 4,090 ± 68 4,116 ± 199 +0.6% 4,200 4,200 -0.0%
CodecBenchmark encode SIMPLE 731 ± 14 726 ± 3 -0.7% 1,216 1,216 -0.0%
MixedCodecBenchmark decode - 29,606 ± 744 28,342 ± 722 -4.3% 50,185 50,429 +0.5%
MixedCodecBenchmark encode - 22,521 ± 944 22,649 ± 609 +0.6% 24,657 24,657 +0.0%

Decisions to review

  1. The boxed type lives on PropertyModel and ConstructorCreator.Plan, not on CodecPlan slots as the plan item said. The conversion happens in setValue and the creator, which never see the codec plan. The boxed type depends only on the property's declared type, not on the datastore, so it fits the shared model (it's computed lazily on first use and isn't copied).
  2. A registered conversion from a type to one of its own supertypes is now skipped. For example, an ArrayList → List conversion never runs because an ArrayList already fits a List property. No default conversion is like that; the review flagged the same risk. A user who needs to rewrite a value of the right type should use a codec.
  3. Found along the way, not fixed here: refresh() of an entity with a drifted field fails on master too. refresh decodes through DocumentReader, whose readString throws ClassCastException on a stored integer instead of BsonInvalidOperationException, so the fallback is never reached. It belongs in its own PR.

PropertyModel.setValue and ConstructorCreator now pass a value straight through when it is already an instance of
the target type, with primitives boxed, instead of calling Conversions.convert. Conversions.convert looks its map up
with get rather than computeIfAbsent, so it no longer inserts an empty map for every new value class. The decoder's
fallback path no longer converts before handing the value to the creator, which converts it again.
Copilot AI balanced review requested due to automatic review settings October 26, 2026 03:54

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

🟡 Changes recommended

Removing fallback property-type conversion breaks schema-drift refreshes and constructor mappings that require an intermediate conversion.

1 open issue
What changed in this PR

This PR implements plan item 3.7 to reduce conversion overhead when Morphia decodes values that already fit their target types.

Changes:

  • Adds boxed-type fast paths for property assignment and constructor arguments.
  • Avoids caching empty conversion maps and removes decoder-side fallback conversion.
  • Adds conversion tests and marks the plan item complete.
File Description
design/​mapping-game-plan.md Marks item 3.7 complete.
core/​src/​test/​java/​dev/​morphia/​mapping/​codec/​TestConversions.java Tests boxing, registered conversions, pass-through values, and null handling.
core/​src/​main/​java/​dev/​morphia/​mapping/​internal/​ConstructorCreator.java Caches boxed parameter types and skips unnecessary conversions.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​PropertyModel.java Adds a boxed-type assignment fast path.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityDecoder.java Delegates fallback conversion to instance creators.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​Conversions.java Adds a boxing helper and avoids empty-map insertion.

Review effort: Balanced

Comment thread core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java Outdated
The refresh codec's creator sets values without converting them, and a constructor parameter can need the property's
type as an intermediate step. The creator's fast path skips the second conversion once the value fits.

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

🟢 Approval recommended

The localized optimizations preserve required fallback conversions and null handling, with regression coverage and no unresolved blocking issues.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@evanchooly
evanchooly merged commit 3843251 into master Oct 26, 2026
5 checks passed
@evanchooly
evanchooly deleted the skip-conversions branch October 26, 2026 10:54
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