Skip to content

Improve class-match construction and matching performance - #162

Merged
mcculls merged 15 commits into
mainfrom
mcculls/class-match-perf
Nov 3, 2026
Merged

mcculls merged 15 commits into
mainfrom
mcculls/class-match-perf

Conversation

@mcculls

@mcculls mcculls commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

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.

ColdMatcherConstructionBenchmark measures the first construction of each graph in a fresh JVM.

MatchingApiBenchmark measures 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:

  1. 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 under or; other combinations retain the predicate fallback. Parameter and return matchers retain the existing descriptor-based checks and first-parameter fast path.

  2. Keep method-name checks at the front of composed matchers. Exact-name and name-predicate matchers override and to 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.

  3. 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.

  4. Specialize declared-member scans for known matcher shapes. declares recognizes 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.

  5. 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.

  6. 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 computeIfAbsent method-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) and 700e16e0db2551d3ca15d42eeaf08a13317603cd (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

Workload Case Before vs current Current speedup vs Byte Buddy
REAL_WORLD Union 1.71× 37.02×
REAL_WORLD Sweep 1.71× 37.04×
CLASS_SELECTION Union 1.79× 41.62×
CLASS_SELECTION Sweep 1.84× 42.93×
DD_TRACE Union 1.64× 15.93×
DD_TRACE Sweep 1.65× 17.58×

DD_TRACE times in µs/class (ISOLATED)

Java Case Before Current Byte Buddy Before vs current
8 Union 69.05 42.20 667.18 1.64×
8 Sweep 119.62 72.37 1254.25 1.65×
11 Union 67.70 41.94 727.94 1.61×
11 Sweep 118.73 72.72 1436.29 1.63×
17 Union 64.81 38.98 621.06 1.66×
17 Sweep 115.69 67.00 1177.94 1.73×
21 Union 64.15 39.32 688.97 1.63×
21 Sweep 115.08 68.70 1356.66 1.68×
25 Union 64.66 39.17 589.85 1.65×
25 Sweep 111.27 67.79 1058.50 1.64×

Cold construction in ms/graph

Workload Case Before Current Byte Buddy Before vs current Current − Byte Buddy
REAL_WORLD Full graph 8.26 4.57 5.31 1.84× -0.70
REAL_WORLD Branches 8.17 4.56 5.14 1.74× -0.72
CLASS_SELECTION Full graph 5.92 3.99 4.98 1.41× -0.99
CLASS_SELECTION Branches 5.88 3.89 4.86 1.41× -0.98
DD_TRACE Full graph 16.09 11.01 11.11 1.32× -0.61
DD_TRACE Branches 15.59 10.41 9.66 1.31× +0.10

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

Workload Case Before vs current Current speedup vs Byte Buddy
REAL_WORLD Union 1.72× 91.92×
REAL_WORLD Sweep 3.48× 113.25×
CLASS_SELECTION Union 1.74× 121.45×
CLASS_SELECTION Sweep 3.21× 127.29×
DD_TRACE Union 2.41× 53.18×
DD_TRACE Sweep 2.41× 36.59×

Warmed class allocation in bytes/class (ISOLATED)

Workload Case Before Current Byte Buddy
REAL_WORLD Union ≈0 ≈0 19,262
REAL_WORLD Sweep ≈0 ≈0 19,978
CLASS_SELECTION Union ≈0 ≈0 19,263
CLASS_SELECTION Sweep ≈0 ≈0 19,754
DD_TRACE Union 453 453 2,381,693
DD_TRACE Sweep 590 590 4,533,065

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:

@mcculls
mcculls marked this pull request as ready for review November 2, 2026 10:13
@mcculls
mcculls requested a review from a team as a code owner November 2, 2026 10:13
@mcculls
mcculls requested review from amarziali, dougqh and ygree and removed request for a team November 2, 2026 10:13
@mcculls mcculls added this to the 0.0.7 milestone Nov 2, 2026
@dougqh

dougqh commented Nov 2, 2026

Copy link
Copy Markdown

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 (run(Lorg/junit/runner/notification/RunNotifier;*), public inject(??)), with the predicate API kept as the compatibility layer and fallback. It returns identical results on all 10,047 classes × 1,191 selectors. Against this PR's class-match on Zulu 21:

  • warm sweep: 92.2 → 5.6 µs (16×); union: 51.1 → 2.8 µs (18×); allocation 589 → 218 B/class
  • cold first use (construct + parse + match the corpus): 1,218 → 164 ms (7.4×)
  • -XX:+PrintInlining on the DD_TRACE sweep: 131 class-match call sites not inlined (virtual call / no static binding) on ClassMatcher::test, MethodMatcher::test and AccessMatcher::test, which is the megamorphism I mentioned. The pattern side has no dispatch failures.

It also turned up three dead selectors in dd-trace-java (contradictory takesArguments/takesArgument), which is the validation argument in practice. Details and caveats (one JDK so far; the corpus has no type gates) are in the draft PR.

Not blocking this PR on it. It's an improvement to the current design. A few scoped items:

  1. method() semantics: it now excludes <init>/<clinit>, and method().or(x) no longer short-circuits to "all". The description says existing selection semantics are preserved, so this should be called out.
  2. Fast-path miss: 491 of the 1,191 selectors use method().and(method("x")), which builds RegularMethod(MethodNamed); declaresMethod doesn't recognize that, so it takes the generic DeclaresMethod path. Unwrapping RegularMethod there would fix it.
  3. AccessMatcher: was the mask-vs-per-class comparison measured on SHARED / DD_TRACE? With ISOLATED, the call site may see too few types to show the megamorphic cost.
  4. Baseline: numbers against main on the same suite would show what these changes bought, alongside the Byte Buddy comparison.
  5. Remaining lambdas: MethodMatcher.access, the FieldMatcher combinators, AccessMatcher.and/or, and StandardMatchers.not/named/nameEndsWith are still lambdas, which doesn't fit the cold-start goal. Worth measuring with ColdMatcherConstructionBenchmark.

@dougqh

dougqh commented Nov 2, 2026

Copy link
Copy Markdown

I think this is a good step towards replacing ByteBuddy matchers.
I like the fluent-style API for backwards compatibility, but I think we can go a bit further in optimizing matching.

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.
Since this piqued my curiosity, I went ahead and did some experimentation of my own (#163). On your DD_TRACE corpus it's another ~11–18x on warm matching and ~7x on cold first use, with identical results across all 10,047 classes × 1,191 selectors.

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.

@mcculls

mcculls commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

I think this is a good step towards replacing ByteBuddy matchers. I like the fluent-style API for backwards compatibility, but I think we can go a bit further in optimizing matching.

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. Since this piqued my curiosity, I went ahead and did some experimentation of my own (#163). On your DD_TRACE corpus it's another ~11–18x on warm matching and ~7x on cold first use, with identical results across all 10,047 classes × 1,191 selectors.

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.

@mcculls

mcculls commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

@dougqh regard these scoped items:

  1. method() semantics: it now excludes <init>/<clinit>, and method().or(x) no longer short-circuits to "all". The description says existing selection semantics are preserved, so this should be called out.

Yes, this is already separated out in #161

  1. Fast-path miss: 491 of the 1,191 selectors use method().and(method("x")), which builds RegularMethod(MethodNamed); declaresMethod doesn't recognize that, so it takes the generic DeclaresMethod path. Unwrapping RegularMethod there would fix it.

This is an anti-pattern which we'll collapse as part of migrating the ByteBuddy matchers - previously you would write something like isMethod().and(named("x")) but now you can use method("x") and just drop that first method() - the sample corpus migrated from dd-trace-java likely still has a few pieces like that which can be neatened up, but for benchmarking purposes it doesn't change the headline results

  1. AccessMatcher: was the mask-vs-per-class comparison measured on SHARED / DD_TRACE? With ISOLATED, the call site may see too few types to show the megamorphic cost.

IIRC it was on SHARED - the larger DD_TRACE corpus was a more recent addition. Given the lack of access modifier checks in dd-trace-java I'll likely go back to the mask approach that I had before, because it means fewer classes to load and I as mention below, most of the time we hardly check the access modifier - the name and/or type is much more frequent.

  1. Baseline: numbers against main on the same suite would show what these changes bought, alongside the Byte Buddy comparison.

Yes, I'll add those.

  1. Remaining lambdas: MethodMatcher.access, the FieldMatcher combinators, AccessMatcher.and/or, and StandardMatchers.not/named/nameEndsWith are still lambdas, which doesn't fit the cold-start goal. Worth measuring with ColdMatcherConstructionBenchmark.

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 ColdMatcherConstructionBenchmark - further refactorings didn't save much time for cold-start, and in a few cases actually made the warm matching execution worse.

@mcculls
mcculls force-pushed the mcculls/class-match-perf branch from c3fca16 to 1794bda Compare November 2, 2026 23:38
@dougqh

dougqh commented Nov 3, 2026

Copy link
Copy Markdown

Thanks. I dug into the gating question. Breaking the speedup down on your DD_TRACE corpus (details in the updated #163):

  • Flattening alone (no index): a FlatMethodMatcher behind your existing fluent API, where and() merges access/name/descriptor slots instead of nesting predicates. Same call sites, identical results. That's ~2× on warm matching (sweep 90.8 → 44.4 µs) and first use (936 → 498 ms), and it doesn't depend on how many selectors reach a class.
  • Name index across selectors: ~15× more on top, but that's the breadth advantage you anticipated, and it should shrink when gating leaves few selectors per class. I've added a gated benchmark that evaluates one instrumentation's selector group per class (~1.5 selectors on average, which is pretty close to your scenario); numbers tomorrow.
  • Construction: string-literal patterns avoid loading the predicate framework (~7 ms vs ~10 ms), regardless of gating.

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 test(), no conjunction chains. A smaller cleanup in the same spirit is a single mask-based AccessMatcher ((acc & mask) == expected, with and closed) in place of the per-constant subclasses. It's only a few percent faster on its own, but it simplifies things and catches contradictions like PUBLIC.and(PRIVATE) at construction.

@dougqh

dougqh commented Nov 3, 2026

Copy link
Copy Markdown

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 (and() merges name / access-mask / descriptor slots behind your existing fluent API, so test() is monomorphic) and simple descriptor matching (? = one type, single pass, no intermediate structures). Together they're ~1.5× gated and ~2× un-gated, with identical results. The bitmask memoization layer you mentioned would sit naturally on top.

@mcculls

mcculls commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

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.

@mcculls
mcculls force-pushed the mcculls/class-match-perf branch from 700e16e to 8ad357d Compare November 3, 2026 14:56
@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Pipelines

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 8ad357d | Docs | View more details | Give us feedback!

@dougqh

dougqh commented Nov 3, 2026

Copy link
Copy Markdown

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.

@mcculls Okay, that plan makes sense to me.
I think memoization, flattening, and descriptor matching likely all make sense.
My main hope with flattening and descriptor matching was that fewer checks need to use the more expensive mechanisms -- like arbitrary predicates.

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.

@mcculls
mcculls merged commit 0ccfd7f into main Nov 3, 2026
7 of 8 checks passed
@mcculls
mcculls deleted the mcculls/class-match-perf branch November 3, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants