Skip to content

Improve class-match construction and matching performance - #162

Draft
mcculls wants to merge 14 commits into
mainfrom
mcculls/class-match-perf
Draft

mcculls wants to merge 14 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 retain a separate implementation class for each check so the JIT can specialize their execution; they are not collapsed into one implementation that reads a configurable mask. 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

The final implementation was measured against Byte Buddy on native ARM64 Zulu Java 8, 11, 17, 21, and 25 on an Apple M1 Max. Ranges span those JDKs.

Warmed class matching

"Union" applies the complete OR graph to each class and stops at the first matching branch. "Sweep" evaluates every branch on each class.

Workload Union speedup over Byte Buddy Sweep speedup over Byte Buddy
REAL_WORLD 30–41× 31–39×
CLASS_SELECTION 32–45× 34–45×
DD_TRACE 14–17× 15–19×

DD_TRACE contains 1,191 selectors extracted from dd-trace-java, with hierarchy and classloader gates removed. Without those gates, selectors with no member name, annotation, or type constraint, such as declares(constructor()), match nearly every class. The 74 such selectors are omitted, so the union matches 4,794 of the 10,047 corpus classes (48%) instead of 94%.

Java Sweep: ClassMatcher Sweep: Byte Buddy Sweep speedup Union: ClassMatcher Union: Byte Buddy Union speedup
8 76.8 µs 1,205 µs 15.7× 44.8 µs 649 µs 14.5×
11 77.9 µs 1,434 µs 18.4× 45.7 µs 715 µs 15.6×
17 70.8 µs 1,186 µs 16.8× 41.5 µs 647 µs 15.6×
21 71.3 µs 1,382 µs 19.4× 41.9 µs 696 µs 16.6×
25 71.0 µs 1,074 µs 15.1× 41.3 µs 596 µs 14.5×

Byte Buddy's DD_TRACE scores vary more than other results, with a relative error of 13–21%. Its per-iteration times swing widely, most likely due to garbage collection, since it allocates megabytes per class. Speedups computed from median iterations agree: 14.2–15.7× for the union and 13.9–17.4× for the sweep.

A DD_TRACE sweep using class-match allocated approximately 590 bytes per class, versus 4.2–5.3 MB for Byte Buddy. A DD_TRACE union match allocated approximately 454 bytes, versus 2.4–2.8 MB. REAL_WORLD and CLASS_SELECTION measured effectively zero allocation for ClassMatcher, versus approximately 18–22 KB for Byte Buddy. Direct method matching was 2.8–5.4× faster in isolation and 2.4–2.8× faster with shared profiling.

Cold construction

Each workload was constructed in two forms: the full graph including its OR union, and the branches alone as independent selectors. Differences are ClassMatcher minus Byte Buddy:

Workload Full graph Branches only
REAL_WORLD +0.12 to +0.93 ms (+2 to +15%) +0.00 to +0.41 ms (0 to +6%)
CLASS_SELECTION −0.93 to −1.06 ms (−16 to −21%) −0.97 to −1.43 ms (−20 to −24%)
DD_TRACE +0.19 to +0.97 ms (+2 to +9%) +0.47 to +1.94 ms (+3 to +21%)

ClassMatcher is faster to construct for CLASS_SELECTION and slower for REAL_WORLD and DD_TRACE. The largest gap is DD_TRACE branches on Java 25 (11.35 vs 9.41 ms).

ClassMatcher allocated less during construction on every tested JDK for these workloads, in both forms. For REAL_WORLD the saving was 6–11%, and for CLASS_SELECTION 16–27%. Full-graph DD_TRACE allocated 57–61% less, but most of that difference comes from how Byte Buddy builds the union: it copies a growing array as each branch is appended, allocating 12.2–12.7 MB for the full graph versus 2.1–2.5 MB for its branches alone. Without the union, ClassMatcher allocated only 0–10% less for DD_TRACE.

Cold timings include first-use matcher class initialization and lambda linkage, excluding JVM process startup. Cold allocation includes initialization and iteration infrastructure overhead, rather than representing steady-state graph size.

Methodology

Cold measurements used 30 fresh JVM forks per case, zero warmup, and one single-shot construction per fork. Warmed matching used 3 forks, each with 5 × 1-second warmup iterations and 10 × 1-second measurement iterations. Runs were serial, used one benchmark thread and a fixed 512 MiB heap, and included GC profiling. Across the full isolated class-execution matrix, median JMH relative error was 0.81%. Every case was below 4.4% except Byte Buddy on DD_TRACE (13–19%, described above).

Benchmark setup checks agreement between the APIs for class graphs, individual branches, and direct method patterns. Fixtures add expected hits and deliberate near misses. Regression tests cover chained constraints and short-circuiting, matching all criteria on the same member, continuing after partial matches, and missing CharSequence name lookups.

The class results above use the ISOLATED profile. Shared method results use a synthetic graph mix rather than production frequency weights. Graph construction and metadata parsing happen before warmed matching measurements. The extracted DD_TRACE graph excludes hierarchy traversal and classloader gates. These are comparisons of the final implementation with Byte Buddy, not measurements of the gain from each individual change or of end-to-end application performance.

Reproduction details: JMH 1.36, Byte Buddy 1.18.16, macOS 26.7.1. Zulu JDK builds were 1.8.0_512-b08, 11.0.33+9-LTS, 17.0.21.0.3+1-LTS, 21.0.13.0.3+1-LTS, and 25.0.5.0.3+1-LTS. Build with ./gradlew :class-match:jmhJar :class-match:sampleBytecode; the benchmark Javadocs document invocation, and the saved manifest records every collection command.

Contributor Checklist

Jira ticket:

This branch has not been deployed

No deployments
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.

1 participant