Skip to content

Prototype descriptor-pattern member matching (for #162 discussion) - #163

Draft
dougqh wants to merge 18 commits into
mainfrom
dougqh/descriptor-patterns
Draft

dougqh wants to merge 18 commits into
mainfrom
dougqh/descriptor-patterns

Conversation

@dougqh

@dougqh dougqh commented Nov 2, 2026 •

Copy link
Copy Markdown

Update: where the speedup comes from

Follow-up to the gating question on #162. The speedup has three separable sources (Zulu 21, Apple Silicon, load ~5–7, identical results on all 10,047 classes × 1,191 selectors):

DD_TRACE #162 predicates flattened (same fluent API, no index) patterns + name index
warm sweep 90.8 µs 44.4 µs (2.0×) 2.9 µs (~31×)
warm union 51.4 µs 25.0 µs (2.1×) 1.8 µs (~29×)
cold first use (construct + parse + match corpus) 936 ms 498 ms (1.9×) 110 ms (~8.5×)
cold construction 10.5 ms 10.0 ms 6.8–7.5 ms
  • Flattening, about 2× regardless of workload: FlatMethodMatcher behind the existing fluent API (Flat.method(...) etc.). and() merges name / access-mask / descriptor slots instead of nesting predicates, and anything opaque goes into one residual. test() is monomorphic. The DD_TRACE selectors switch over with an import change (DDTraceFlatGraphs).
  • Name index, about 15× more on top, workload-dependent: this is the breadth advantage of checking every selector against every class. It should shrink when type/loader gates leave few selectors per class. PatternGatedBenchmark (per-instrumentation groups, ~1.5 selectors each) measures that; gated numbers to follow.
  • String-literal patterns, ~3 ms less construction regardless of gating: they avoid loading the predicate framework.

Also in this update:

  • AccessMask: one (mask, expected) class replaces the per-constant AccessMatcher subclasses, and and is closed (PUBLIC.and(PRIVATE) becomes constant-false at construction). A cleanup, measured as only a few percent faster on its own.
  • Grammar: ? is a hole for exactly one type, including the return ((??)?). * is kept for open-ended arity, with prefix fast paths: (L..;*) is a single regionMatches.
  • A build-step experiment (separate commit, negative result): precompiling all patterns into generated code was slower to construct (9.3 vs 6.9 ms) and no faster on first use. Laziness wins.

What Does This Do

A prototype for discussion on #162, not intended to merge as-is. It shows an alternative representation for member matching: lazily-compiled JVM descriptor patterns behind a member-name index, in place of predicate graphs. It's stacked on #162 so it can reuse that PR's DD_TRACE corpus, harness, and agreement checks, and so the comparison is against #162's optimized predicates rather than main.

run(Lorg/junit/runner/notification/RunNotifier;*)
public inject(??)
<init>(??Lcom/amazonaws/services/sqs/model/Message;*)
!static apply(Ljava/lang/String;)Lscala/Option;
@javax/ws/rs/Path *
  • MemberPattern: ? matches exactly one parameter; * matches the remaining parameters and is tail-only, so matching is a single linear pass with no backtracking. Patterns also support modifiers (public, !static, bridge…), name alternation (a|b), prefix* / *suffix names, and an @annotation token. About 60% of patterns need only one String operation after the name lookup (equals / startsWith / endsWith); the rest use a token walk that doesn't allocate.
  • PatternIndex: construction only stores references. On first use it routes patterns into tiers (exact names, name prefix/suffix, annotations, wildcard names), and each pattern compiles the first time a candidate member reaches it. The few shapes patterns can't express fall back to Improve class-match construction and matching performance #162's predicates.
  • tools/pattern-translator: translates the generated DD_TRACE corpus mechanically by compiling it against a mirror API (so javac does the parsing) and expanding and/or into disjunctive normal form. Output: DDTracePatterns (1,287 lines vs 6,106 for the predicate graph).
  • PatternMatchingBenchmark / PatternColdBenchmark: warm and cold comparisons. Setup checks agreement with the class-match graphs on the full corpus.

Motivation

#162's Motivation names two costs: matchers that are "expensive to construct for the first time," and "a convenient chain of predicates [that] can introduce dispatch overhead for every member examined." #162 reduces both within the predicate model. This prototype tests whether changing the representation removes them instead.

Results

Correctness: 1,183 of 1,191 selectors translate to patterns (8 predicate fallbacks remain). Results are identical on all 10,047 classes × 1,191 selectors (~12M comparisons).

Zulu 21, Apple Silicon. Warm: 3 forks × 10 × 1 s. Cold: 30 fresh-JVM forks.

DD_TRACE #162 class-match patterns
warm sweep (which selectors match a class) 92.2 µs 5.6 µs 16×
warm union (does any selector match) 51.1 µs 2.8 µs 18×
warm parse + sweep (fresh strings) 90.8 µs 8.3 µs 11×
allocation per class (sweep / union) 589 / 453 B 218 / 79 B
cold construction 11.4 ms 8.1 ms 1.4×
cold first use (construct + parse + match the whole corpus) 1,218 ms 164 ms 7.4×

These are from the first version (39 fallbacks), measured on a quiet machine. The tiered version (8 fallbacks) was only measured on a loaded machine: union 2.1 µs vs 50.8 µs, first use 156 vs 1,037 ms. A quiet rerun is pending before quoting tiered figures.

Cold construction is close to all-or-nothing. All 1,191 pattern literals construct in 1.7 ms, but even the last 8 predicate fallbacks cost 7–9 ms, because building any predicate loads the class-match matcher framework (all 15 AccessMatcher classes, InternalMatchers…). Pre-warming the lambda/indy infrastructure didn't change this. With zero fallbacks, construction should be about 2 ms; the remaining 8 need small grammar extensions (a type glob in a parameter slot, AND of pattern groups, name exclusion, a name glob with a middle *).

Inlining (-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining, warmed DD_TRACE sweep): C2 refuses to inline 131 class-match call sites with virtual call / no static binding, on ClassMatcher::test, MethodMatcher::test, FieldMatcher::test and AccessMatcher::test. On the pattern side there are no dispatch failures (MemberPattern is one final class, and variation is a switch on an int); its remaining misses are size heuristics that restructuring can fix.

Laziness: on this corpus, 64% of patterns get compiled over a full pass, because instrumentation targets common names (<init>, run, execute, get). The corpus removes dd-trace-java's type gates, so this is a worst case; with the gates, far fewer patterns would compile.

Additional Notes

Found by the translator: three dead selectors on dd-trace-java master (all IAST). Byte Buddy silently never matches them:

  • akka-http-10.0 and pekko-http-1.0 iast/MarshallingDirectivesInstrumentation: takesArguments(1).and(takesArgument(1, …Unmarshaller))
  • org-json-20230227 JSONArrayInstrumentation: takesArguments(0).and(takesArgument(0, JSONTokener))

That's the validation argument in concrete form: patterns are data, so a build-time pass can reject malformed patterns and resolve them against target jars. Predicate graphs can only be executed.

Caveats:

Reproduce: ./gradlew :class-match:jmhJar :class-match:sampleBytecode, then run PatternMatchingBenchmark / PatternColdBenchmark from class-match/. To regenerate patterns: tools/pattern-translator/record.sh | tools/pattern-translator/generate.py <graphs> <out>.

Contributor Checklist

Jira ticket: [PROJ-IDENT]

🤖 Generated with Claude Code

mcculls and others added 15 commits October 31, 2026 00:48
…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.
Express member selectors as lazily-compiled JVM descriptor patterns
(e.g. "run(Lorg/junit/runner/notification/RunNotifier;*)", "public inject(??)")
matched through a member-name index, instead of predicate graphs.

- MemberPattern: grammar ('?' = one param, tail-only '*' = remaining params,
  modifiers, name alternation/prefix/suffix, @annotation) compiled to a single
  String op where possible, else a linear non-allocating token walk.
- PatternIndex: tiered index (exact names, name prefix/suffix, annotations,
  wildcard names) with per-pattern lazy compilation; predicate fallback for the
  few shapes patterns can't express.
- tools/pattern-translator: records the DD_TRACE corpus against a mirror API
  (javac does the parsing) and generates DDTracePatterns.
- PatternMatchingBenchmark / PatternColdBenchmark: warm and cold comparison
  against the class-match graphs, with a full-corpus agreement check in setup.

Prototype for discussion on #162; not intended to merge as-is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Pipelines

✨ Unblock PR with BitsAI

❌ Errors

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 1 Pipeline job failed

DataDog/dd-instrument-java | build — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

Useful? React with 👍 / 👎

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

@mcculls

mcculls commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks - interesting idea.

The next step of the class-match roadmap is introducing 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 and others added 3 commits November 2, 2026 19:36
…ast paths

- FlatMethodMatcher + Flat: a flattened macro step (name, access mask,
  descriptor, then one residual predicate) built through the existing fluent
  API; and() merges slots instead of nesting predicates, so test() stays
  monomorphic. DeclaresFlatMethod gives declares() a monomorphic loop.
  DDTraceFlatGraphs (generated by tools/pattern-translator/flat-graphs.sh) is
  the DD_TRACE corpus switched to Flat by an import change.
- AccessMask: a single (mask, expected) class replacing the per-constant
  AccessMatcher subclasses; and() is closed (NEVER on contradiction);
  general and/or use named classes instead of lambdas.
- MemberPattern: '?' as a hole for exactly one type, including the return;
  '*' kept for open-ended arity with prefix fast paths ("(L..;*)" is a single
  regionMatches, "(L..;*)R" adds the return check).
- PatternGatedBenchmark: per-instrumentation-group evaluation (types/loader
  gates assumed passed) comparing predicates, a per-group index, and a linear
  loop over flattened patterns; RANDOM and HITS pairings.
- Translator: instrumentation groups emitted per selector; STRICT mode
  (no '*') for measuring how much '?' alone covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generates DDTracePrecompiled with every pattern compiled ahead of time as
MemberPattern.of(...) calls, and adds constructPrebuilt / firstUsePrebuilt to
PatternColdBenchmark.

Kept as evidence, not a direction: on Zulu 21 construction was slower
(9.3 vs 6.9 ms lazy) and first use no faster (134±26 vs 124 ms). Running
1,275 constructor calls interpreted in cold code costs more than storing
string references and lazily compiling only the ~824 patterns ever hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MemberPattern: names and literal are legitimately null (wildcard names,
  ANY descriptor), so mark them @nullable.
- PatternColdIndyBenchmark: drop the unread public sink field; consume the
  warm-up lambda's result in setup instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mcculls
mcculls force-pushed the mcculls/class-match-perf branch from 700e16e to 8ad357d Compare November 3, 2026 14:56
Base automatically changed from mcculls/class-match-perf to main November 3, 2026 18:10

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.

2 participants