Repository navigation
Improve class-match construction and matching performance - #162
Conversation
|
Following up on my inline comments with data. I prototyped the alternative I had in mind, stacked on this PR so it uses your DD_TRACE corpus, harness, and agreement checks: #163 The idea is lazily-compiled descriptor patterns behind a member-name index (
It also turned up three dead selectors in dd-trace-java (contradictory Not blocking this PR on it. It's an improvement to the current design. A few scoped items:
|
|
I think this is a good step towards replacing ByteBuddy matchers. Unfortunately, the JVM doesn't cope well with call sites that see many implementations of the same interface: once a site sees more than a couple of receiver types, C2 stops inlining it. So while moving away from lambdas does help start-up time, I think the functional style is still costing us on warm-up and execution time. If we compile the AND predicates into a simpler form, I think we can improve execution time considerably. And I think if we replace the specialized name / descriptor matchers with simple string literals, and index them by member name across all selectors, we can do better still. Admittedly, getting the full benefit does require updating our matchers, but most of that can be done mechanically — the translator in #163 converted all but 8 of the 1,191 selectors — so I think it could be worth it. |
@dougqh thanks for putting this together, it's an interesting idea. One thing we need to account for is composing matcher results across type hierarchies. I see your prototype already preserves individual selector results, but we also need to support queries like “match Foo if it matches group 1 and none of its superclasses match group 2.” The translator currently falls back for class-level conjunctions, where different members can satisfy each condition. Supporting those and hierarchy queries would need some additional machinery around the member patterns. I'd also be interested in how much of the speedup carries over with realistic type and classloader gates. The benchmark checks every selector against every class, which gives the global name index plenty of opportunity to help; after gating, there may be far fewer relevant selectors. We're planning a memoization layer so each matcher is evaluated once per class and its result reused across hierarchy queries. That will also use indexing and bit masks to track matcher results, so there may be a way to combine the two approaches. |
|
@dougqh regard these scoped items:
Yes, this is already separated out in #161
This is an anti-pattern which we'll collapse as part of migrating the ByteBuddy matchers - previously you would write something like
IIRC it was on SHARED - the larger DD_TRACE corpus was a more recent addition. Given the lack of access modifier checks in
Yes, I'll add those.
That's intentional - with the initial set of lambdas/method-references, the sheer amount of classes being built via the LambdaMetaFactory meant cold-start was initially 5ms more than Byte Buddy. Rather than eliminate all lambdas, which would have affected readability, we took a more targeted approach and converted them in logical groups based on expected usage. For example, it's rare for access matchers to be combined - typically only one such matcher applies to a given matcher group. Likewise field matching is typically focused on the name and type, and ignores any access modifier. The same goes for ends-with matching which is again rarely used. The current set is the result of measuring different amounts of lambda refactorings with |
c3fca16 to
1794bda
Compare
|
Thanks. I dug into the gating question. Breaking the speedup down on your DD_TRACE corpus (details in the updated #163):
On hierarchy queries: the index already produces the per-class set of matching selectors, which looks like exactly the bitmask your memoization layer wants, so the two seem to fit together naturally. Class-level conjunctions fit the same model (an AND over pattern groups, each with its own bit). The most adoptable piece is probably flattening inside class-match: same API, monomorphic |
|
Gated numbers (one instrumentation's selectors per class, ~1.5 on average), JDK 21: flattening is ~1.5× (232 → 148 ns per class on mostly-miss pairs, 408 → 279 ns when the gate passes on the right class). As you expected, a cross-selector index adds little once gating has narrowed things, so I'd drop it from the proposal. What I'd actually propose is the two pieces that hold up either way: flattening ( |
|
Hi @dougqh, thanks for following up with the gated measurements. Both approaches look useful, and I particularly like the idea of flattening behind the fluent API. The aim of this PR was to prove out the new fluent API, uncover gotchas (such as the roughly 5 ms cold-start penalty from lambda linkage, which this PR addresses), and establish where we stood relative to Byte Buddy. I’d like to focus on hierarchy memoization next in a separate PR, then revisit flattening and descriptor matching once that’s in place. That should give us a clearer picture of how these pieces fit together and leave room for the memoization design to take shape. |
…inkage cost Keep a separate class per matcher so the JIT can specialize by matcher type; avoid replacing these with a shared class holding a mask field as it slows down matching.
700e16e to
8ad357d
Compare
|
This comment will be updated automatically if new data arrives. 🔗 Commit SHA: 8ad357d | Docs | View more details | Give us feedback! |
@mcculls Okay, that plan makes sense to me. As for the branching of flattening - from my benchmarking other situations, it turns out to be a win. After all the way the JVM handles multiple classes is just to use conditionals, they just aren't as fully within your control. |
What Does This Do
Adds benchmarks for constructing and executing equivalent class-match and Byte Buddy matcher graphs, and uses them to guide improvements to matcher construction and execution. The changes reduce first-use lambda linkage, keep common name checks visible to the JIT, and avoid layers of predicate dispatch when scanning declared members.
The benchmarks cover common instrumentation class selectors, direct advice method selectors, and a large graph of 1,191 selectors extracted from dd-trace-java. The public matcher API and existing selection semantics are preserved.
Motivation
Instrumentation builds matcher graphs during startup and then applies them repeatedly to candidate classes and methods. These paths need separate measurements: a matcher that executes efficiently can still be expensive to construct for the first time, while a convenient chain of predicates can introduce dispatch overhead for every member examined.
ColdMatcherConstructionBenchmarkmeasures the first construction of each graph in a fresh JVM.MatchingApiBenchmarkmeasures warmed execution over unloaded classes from Spring, Jackson, Reactor, Tomcat, and Apache HttpClient jars, together with generated controller and selection fixtures.Direct method benchmarks exercise exact-name, name-set, and regular-method-plus-name selection over Spring Web and Reactor methods.
Together, these benchmarks guide the following changes:
Reduce first-use lambda linkage. Common access, annotation, class, field, method, parameter, return-type, and type-name predicates use explicit implementations in place of lambdas or method references. Constructing these matchers no longer requires linking those lambda call sites. Stateless predicates, including constructor, static-initializer, and no-parameter checks, can be reused. Access predicates share one mask-based implementation. Positive masks test for selected flags, while complemented masks test for their absence. Compatible absence checks combine under
and, and compatible presence checks combine underor; other combinations retain the predicate fallback. Parameter and return matchers retain the existing descriptor-based checks and first-parameter fast path.Keep method-name checks at the front of composed matchers. Exact-name and name-predicate matchers override
andto retain the name check in their concrete implementation and combine the remaining constraints behind it. For example,method("execute").access(PUBLIC).noParameters()checks the name before evaluating access and parameters, without putting the name predicate behind another generic conjunction. A name miss short-circuits before the remaining criteria are dispatched.Collapse method matcher wrappers into their base implementations. Bare and constrained exact-name, name-predicate, and regular-method matchers share their respective implementation classes, using an optional remaining matcher. This reduces the number of receiver types at hot predicate call sites and lets the JIT see the common name or regular-method check directly.
Specialize declared-member scans for known matcher shapes.
declaresrecognizes exact field names, optionally with a type, and exact or predicate-based method names. It selects loops that check those criteria directly instead of invoking a generic member predicate on every entry. Remaining method constraints run only after the name matches. The loops continue searching after a partial match, and arbitrary matcher compositions retain the generic fallback, so all criteria must still match the same member.Skip methods without annotations of interest. For
declares(method().annotatedWith(...)), a specialized loop checks the parsed annotation array first. Most methods have no annotations of interest, so they can be skipped before checking the method name or invoking the annotation predicate. Candidates still have to satisfy the regular-method and annotation criteria.Simplify annotation registration. Registration now explicitly updates the annotation-name and descriptor-key mappings, skipping names already registered. A batch copies the published annotation snapshot once, adds only new entries, and publishes the completed snapshot under the existing lock. This removes the
computeIfAbsentmethod-reference path and avoids reinserting existing mappings while preserving copy-on-write publication for readers.Additional Notes
Headline scope complete: 520/520 requested rows validated (520/960 of the original full matrix).
Headline collection complete: cold construction for all seven workloads, plus warmed REAL_WORLD, CLASS_SELECTION and DD_TRACE in ISOLATED and SHARED profiles, across all five JDKs and both revisions. Direct method matching and the four remaining warmed class workloads were deferred to release the machine. Their previous numbers are not carried forward into this update.
Re-ran both
8ce679063c72d335c04c2fd1820337c722d26086(before optimizations) and700e16e0db2551d3ca15d42eeaf08a13317603cd(current PR head, including the mask-based AccessMatcher). Both revisions use identical benchmark sources, dependencies and corpus; Byte Buddy was freshly measured at both revisions.Summary values are medians across Java 8, 11, 17, 21 and 25. Each JDK contributes its JMH mean; speedups and timing differences are calculated within each JDK before taking the median. Before vs current is baseline ClassMatcher time divided by current ClassMatcher time; above 1× means the optimized version is faster. Per-JDK results remain in the detailed report and the DD_TRACE table below.
Warmed class matching, ISOLATED profile
DD_TRACE times in µs/class (ISOLATED)
Cold construction in ms/graph
Current − Byte Buddy is the median paired difference across JDKs; a positive value means ClassMatcher took longer. These measure cold graph construction in a new JVM, including first-use initialization and lambda linkage, and excluding JVM process startup.
Current ClassMatcher has lower mean cold-construction time than Byte Buddy for REAL_WORLD and CLASS_SELECTION on every tested JDK, in both forms. For DD_TRACE, the median full-graph construction is 0.61 ms faster than Byte Buddy, while branches alone are 0.10 ms slower. The direction of the DD_TRACE comparison varies by JDK; the individual results and confidence intervals remain in the detailed report.
SHARED class matching
Warmed class allocation in bytes/class (ISOLATED)
Uncertainty: 132/520 rows have JMH relative error above 10%; 18/130 baseline/current ClassMatcher pairs have overlapping 99.9% confidence intervals. Median Byte Buddy control drift across revisions is +0.02%; individual cases vary substantially. Ratios are point estimates; inspect per-case uncertainty and control drift in the detailed results before attributing small differences to code changes.
Cold construction: 30 fresh JVM forks, no warmup, one single-shot construction per fork. Warm matching: 3 forks, 5 × 1 s warmup, 10 × 1 s measurement. Serial runs, one thread, 512 MiB heap, GC profiling, native ARM64 Zulu on Apple M1 Max. JMH 1.36; Byte Buddy 1.18.16. Revisions are paired per case with alternating execution order.
Cold timings include first-use initialization and lambda linkage, excluding JVM startup. Cold allocation includes initialization and iteration overhead. Warm setup constructs graphs and parses metadata before measurement. DD_TRACE has 1,191 selectors with hierarchy and classloader gates removed. Setup validates API agreement for graphs, individual branches and method patterns. SHARED is a synthetic graph mix; its scores average over that mix. These measurements do not establish an end-to-end application speedup.
Contributor Checklist
Jira ticket: