Skip to content

Cut repeated lookups on the validation path - #273

Open
jungm wants to merge 5 commits into
perf-1-messagesfrom
perf-2-lookups
Open

jungm wants to merge 5 commits into
perf-1-messagesfrom
perf-2-lookups

Conversation

@jungm

@jungm jungm commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Summary

Removes repeated lookups that every validate() call performed.

 ValidatorImpl.validate(bean)
   ValidationJob.getResults
-    Lazy<Set> results
+    plain fields                           # keeps getResults() small enough for C2 to inline
     ValidateBean.hasWork
-      unwrap proxy class → getBeanDescriptor (CHM write on every hit)
+      getBeanDescriptorForRuntimeClass        # one lookup, keyed by the (proxy) class
     computeBaseFrame
-      getBeanDescriptor again
+      reuse the descriptor from hasWork
     groups.asStrategy()
-      build a new composite strategy
+      cached; strategy hash codes cached; single explicit group resolved once
     PropertyD.getValue
-      Field#get / Method#invoke
+      cached MethodHandle, reflection as fallback
     getConstraintValidator
+      memo on ConstraintD
       factory-wide validator cache

Evidence

  • Before / After: benchmarks

Numbers are JMH throughput (-p provider=bval -f 2 -wi 2 -i 3, 1 s iterations, -prof gc, JDK 25), measured against the PR below this one. The machine was busy, so throughput carries wide error bars; the bytes allocated per operation (B/op) are deterministic.

Benchmark Before After Change B/op before → after
Jsr303Benchmark.bvalNoConstraints (ops/s) 71,836,297 ± 29% 159,318,160 ± 10% 2.22× 0 → 0
validateProperty (ops/ms) 1,010 ± 64% 2,026 ± 4% 2.01× 2,027 → 1,795
mixedDrivers (ops/ms) 353 ± 70% 536 ± 27% 1.52× 5,848 → 5,422
explicitGroup (ops/ms) 577 ± 6% 734 ± 11% 1.27× 5,757 → 4,930
All benchmarks
Benchmark Before After Change B/op before → after
cascadedCycles (ops/ms) 2.30 ± 12% 2.42 ± 14% 1.05× 1,259,572 → 1,202,427
cascadedList (ops/ms) 0.47 ± 15% 0.32 ± 33% 0.67× 6,110,831 → 5,778,430
classLevelInvalid (ops/ms) 683 ± 21% 516 ± 61% 0.76× 4,416 → 3,896
classLevelValid (ops/ms) 1,011 ± 16% 978 ± 68% 0.97× 3,248 → 3,040
composedInvalid (ops/ms) 502 ± 2% 669 ± 5% 1.33× 5,652 → 4,712
composedValid (ops/ms) 811 ± 13% 1,042 ± 5% 1.28× 3,172 → 2,976
containerElements (ops/ms) 1.66 ± 7% 1.84 ± 19% 1.11× 1,681,221 → 1,623,248
deepGraph (ops/ms) 90.9 ± 10% 87.9 ± 68% 0.97× 32,275 → 31,289
elMessage (ops/ms) 696 ± 15% 657 ± 99% 0.94× 4,196 → 3,840
executableParameters (ops/ms) 424 ± 21% 532 ± 10% 1.26× 6,211 → 5,761
executableReturnValue (ops/ms) 388 ± 19% 496 ± 12% 1.28× 6,841 → 6,255
explicitGroup (ops/ms) 577 ± 6% 734 ± 11% 1.27× 5,757 → 4,930
mixedDrivers (ops/ms) 353 ± 70% 536 ± 27% 1.52× 5,848 → 5,422
validateProperty (ops/ms) 1,010 ± 64% 2,026 ± 4% 2.01× 2,027 → 1,795
validateValue (ops/ms) 2,109 ± 40% 2,479 ± 45% 1.18× 1,325 → 1,070
wideBeanInvalid (ops/ms) 59.2 ± 30% 68.1 ± 44% 1.15× 31,759 → 28,157
wideBeanValid (ops/ms) 134 ± 41% 72.0 ± 92% 0.54× 17,553 → 17,308
Jsr303Benchmark.bvalBootstrapAndValidate (ops/s) 2,844 ± 48% 1,652 ± 61% 0.58× 245,596 → 250,395
Jsr303Benchmark.bvalBuildFactory (ops/s) 11,275 ± 3% 3,164 ± 43% 0.28× 87,993 → 89,026
Jsr303Benchmark.bvalConstraintsFailure (ops/s) 590,674 ± 16% 471,733 ± 46% 0.80× 5,520 → 5,000
Jsr303Benchmark.bvalConstraintsFailureReuseValidator (ops/s) 607,050 ± 8% 514,364 ± 60% 0.85× 5,520 → 5,048
Jsr303Benchmark.bvalConstraintsSuccess (ops/s) 1,001,414 ± 37% 826,895 ± 91% 0.83× 3,212 → 3,068
Jsr303Benchmark.bvalConstraintsSuccessReuseValidator (ops/s) 936,769 ± 49% 1,133,176 ± 70% 1.21× 3,248 → 3,080
Jsr303Benchmark.bvalNoConstraints (ops/s) 71,836,297 ± 29% 159,318,160 ± 10% 2.22× 0 → 0
Jsr303Benchmark.bvalNoConstraintsReuseValidator (ops/s) 119,638,823 ± 13% 147,841,169 ± 67% 1.24× 0 → 0
  • Escape analysis: without the results change, the descriptor lookup commit pushes getResults() over C2's inline size limit (failed to inline: already compiled into a big method). bvalNoConstraints then drops from 114M to 49M ops/s and starts allocating 88 B/op. With the results change it stays at 0 B/op.
  • Tests: bval-jsr unit tests: 255 run, 0 failures · TCK: 1070 run, 0 failures, 1 skipped

Merge Danger

Door: two-way

Blast Radius: property access

  • Property reads go through MethodHandles and fall back to reflection when no handle can be created. Exceptions are wrapped as before (IllegalArgumentException, around an InvocationTargetException for getters).
  • The runtime-class cache keeps one entry per validated proxy class for the lifetime of the factory, like the existing descriptor cache.
  • jsr303.bvalBuildFactory/bvalBootstrapAndValidate show drops in this run with unchanged allocation; they recover in the PRs above, which points to noise.

jungm added 5 commits October 5, 2026 13:32
Hold the violations of a constraint in a plain list with a containment
check, as it typically holds a single violation, and the violations of a
validation in a plain field.

Without the Lazy holders, getResults() also compiles small enough for C2
to inline it into its caller, so that a validation that finds no work to
do allocates nothing.
- Cache descriptors keyed by the runtime (possibly proxy) class of the
  validated object, so that each validation needs one map lookup instead
  of unwrapping the class and then looking up its descriptor.
- Stop writing to a ConcurrentHashMap on every bean descriptor cache hit.
- Resolve the root bean descriptor once per validation rather than once
  in hasWork() and again for the base frame.
- Cache Groups#asStrategy() instead of building a new composite strategy
  for every validation.
- Cache the hash codes of group strategies, which key per-strategy
  caches.
- Cache the groups computed for a single explicitly requested group, so
  that validating a group does not re-resolve it on every call.
Field#get and Method#invoke take a slow path when the reflective object
is not a JIT constant, which is the case for descriptors held in caches.
Reflection remains the fallback, with the same exception wrapping.
- Record each constraint's initialized validator on its descriptor, so
  the factory-wide validator cache is consulted once per constraint.
- Look up cached unwrapping information before going through
  computeIfAbsent.
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.

1 participant