Repository navigation
Conversation
Precompute per-bean and per-group validation plans instead of recomputing them on every validate() call, and remove allocations from the per-constraint path: - Cache the constraints matching a group strategy per element descriptor, the Default-group redefinition per bean descriptor, and the properties that need a frame for a group (constraints in scope, cascaded or with container element constraints). Properties with nothing to validate are no longer read. - Skip the traversable resolver and the path copies it needs when the default resolver runs without JPA. - Read property values through cached MethodHandles rather than Field#get/Method#invoke, which are slow when the reflective object is not a JIT constant. - Stop writing to a ConcurrentHashMap on every bean descriptor cache hit; resolve the root bean descriptor once per validation. - Precompute the deep cascaded flag of container descriptors. - Memoize the constraint message template and cache fully interpolated messages that need no EL evaluation, keying on the attribute map's identity. - Cache Groups#asStrategy() and group strategy hash codes. - Replace Lazy holders, streams and hash sets on the per-constraint path with plain fields and lists; back PathImpl with an ArrayList; compute violation and node hash codes without varargs arrays.
- Build a bean's property frames on first use per group, so properties relevant only to groups that are never validated (such as later steps of a failing group sequence) are neither read nor resolved. - Front the per-group descriptor caches with a small identity-matched array, as validation keeps passing the same strategy instances. - Derive a violation's path directly from the parent context instead of materializing the context's own path and copying it again.
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.
ScenarioBenchmark runs each scenario against both providers, selected by a JMH parameter, and checks the expected violation counts once per trial: mixed valid/invalid beans, explicit groups, validateProperty/Value, parameter and return value validation, cascading into large collections and cycles, container element constraints, deep graphs, beans with many built-in constraints (valid and fully invalid), composed and class-level constraints, and EL message expressions. Also run the JMH annotation processor explicitly, as JDK 23+ no longer does so implicitly, and register Tomcat's ExpressionFactory ahead of the bval-jsr test fixture that re-resolves its delegate on every call.
ELFacade ran a look-behind regular expression with over a hundred
alternations across every message containing "${" to disarm unescaped
"#{" sequences. It dominated the cost of any violation whose message
uses EL, including several built-in default messages. A direct scan
gives the same result and returns immediately when there is no "#{".
- Match the default e-mail address pattern with a hand-written matcher equivalent to DEFAULT_EMAIL_PATTERN. - Check the default ".*" regexp of @Email by looking for line terminators instead of running the regular expression. - Cache the groups computed for a single explicitly requested group, so that validating a group does not re-resolve it on every call and the per-strategy descriptor caches are hit by identity.
- Cache the runtime container key per container class on container element descriptors instead of resolving type arguments reflectively for every cascaded container. - Find the ancestor of a cascaded container element by comparing paths in place, and derive the element's context lazily from that ancestor. - Precompute container element descriptors as a flat array and drop the synchronized holder from value extraction. - Memoize each constraint's validator on its descriptor and look up cached unwrapping information before computing it. - Do not build violations of constraints composing one reported as a single violation; only whether they failed matters. - Cache executable descriptors per executable, resolve them once per validation, reuse the executable's base path and the bean's Default group redefinition, and create parameter frames without streams. - Look up per-group descriptor caches without allocating a lambda.
- Resolve a bean's local group strategy and relevant properties with a single per-group plan lookup. - Validate a single-group strategy directly instead of through GroupStrategy#applyTo callbacks, and cascade into container elements without capturing lambdas or a property frame holder object. - Find the ancestor of a cascaded container element without materializing the element's path when it is its parent's plus one node. - Cache default parameter names per executable, and map wrapper types to primitives with a lookup rather than a scan.
|
Most look ok and in valid assumption zone but wonder why the pattern validator doesnt apply the same trick for all regexes (if some specific char are in the regex it can be tested with indexof before using the compiled pattern). Also I think it can be worth running the bench with only one change at a time to ensure they are all needed (if one gives you 100% of boost but you put a cache in front in next round and get another 100% of boost it means second one gave you 200% of boost and first one is useless for ex). Agree on the side note/open point: i know llm love package scope code cause it is trivial to test but we tend to avoid it so maybe worth adjusting? |
|
I think we should split it, the current PR is a bit big |
|
And as you said, we can track the improvement of each PR |
|
Thanks for the feedback! I split this into a stack of smaller PRs. Each one is measured against the PR below it, so every change has to show its own gain:
The top of the stack has exactly the same |
This is an experiment on improving perfomance. With these patches BVal is ~2–38× faster than
master(median ~7.7×) and ahead of Hibernate Validator 10 on all 25 benchmarks; before, it was 3–14× slower than HV on most validation paths. I didn't magically understand every little detail of BVal and profile it to hell and back, this was done with the help of Claude starting out more as an experiment to see how far it is able to go. It has gone A LOT further than i anticipated.Happy to split this into multiple PRs to make it easier to digest, admittedly that diff is large. But wanted to get this out there and maybe some first feedback?
Benchmarks (ops/ms, higher is better)
JMH 1.37, JDK 25, 1 thread, 2 forks × 5 iterations. Before =
master(aab2f37), HV = 10.0.0-SNAPSHOT (Validation 4.0 branch). Non-idle machine, so ratios are ±20–30%.validatePropertyvalidateValueWhat changed and why
GroupStrategyon the descriptors. Previously they were recomputed with streams on every call, and every constrained property was read even when nothing in the requested groups applied to it. Lookups check a small identity-keyed array before theConcurrentHashMap, because validation keeps passing the same strategy instances.MethodHandles:Field#get/Method#invoketake a slow path when the reflective object isn't a JIT constant (~48% of simple validation). Reflection remains the fallback, with the same exception wrapping.ELFacaderan a ~126-alternation look-behind regex over every${message to disarm#{, and several built-in default messages contain EL. A direct scan produces identical output (38× on the EL benchmark).DefaultTraversableResolverwith no JPA, since it can only say yes and calling it forced path copies. The exact-class check keeps subclasses honored.@ReportAsSingleViolation: violations of the composing constraints are discarded anyway, so they are no longer built (path, interpolation, hashing).Lazyon the per-element path are gone.Executableinstead of hashing a newSignaturetwice per call, and default parameter names are cached.@Email: a hand-written matcher replacesDEFAULT_EMAIL_PATTERN(fuzzed against it on 4M strings), and the default.*regexp is checked by scanning for line terminators.DescriptorManager#getBeanDescriptorwrote to a CHM on every cache hit; it no longer does.ConstraintD.PathImplis backed by anArrayList.NodeImpl/ConstraintViolationImplhash codes are computed without varargs arrays.Worth knowing
isReachableis now only called for properties that are actually validated, as HV does.HashSet.DefaultParameterNameProvider: it returns cached unmodifiable lists.PathImplchanged.DescriptorManager#getCachedBeanConstrainedwas removed;ValidateParameters.parameterNamesis now private; there are some new public methods on internal classes.bval-perf):ScenarioBenchmarkwith 17 scenarios for both providers; it asserts equal violation counts before measuring.<proc>full</proc>, because JDK 23+ skips annotation processing and no benchmark list was being generated.ExpressionFactoryahead of the test-jarDelegateExpressionFactory, which does aServiceLoaderlookup per call.ELFacadereplaces unescaped#{with a literal$0. That is kept as-is here, but it was probably meant to be\#{.