Skip to content

Reuse collected sub fields for objects of the same type - #4501

Open
Meemaw wants to merge 1 commit into
graphql-java:masterfrom
Meemaw:perf/reuse-collected-sub-fields
Open

Meemaw wants to merge 1 commit into
graphql-java:masterfrom
Meemaw:perf/reuse-collected-sub-fields

Conversation

@Meemaw

@Meemaw Meemaw commented Oct 18, 2026

Copy link
Copy Markdown

Problem

ExecutionStrategy.completeValueForObject runs FieldCollector.collectFields for every object in the result. For a list of N objects this repeats the same work N times: walking the selection set, evaluating @skip/@include, expanding fragments and building a fresh LinkedHashMap + MergedFields + MergedSelectionSet.

The result only depends on the merged field, the resolved object type, the variables and the fragments, and the last two are fixed for an execution. Async-profiler on ExecutionBenchmark showed field collection at ~16% of CPU and ~20% of allocated bytes.

Change

The collected sub fields are memoized per execution in ExecutionContext, keyed by MergedField and then by the resolved GraphQLObjectType. Every object of the same type under the same field reuses the same immutable MergedSelectionSet. Sub fields are shared too, so cache hits cascade down the tree.

  • Key correctness: MergedField.equals compares the class and the AST Field nodes (Field uses identity), so two merged fields are only equal when they come from exactly the same set of AST fields. Interface/union results are keyed per concrete object type.
  • Fallback to the old per-object collection when:
    • incremental support (@defer) is enabled, because DeferredExecution instances are created per collection, or
    • a custom ConditionalNodeDecision is in the GraphQLContext, because the callback is not guaranteed to be deterministic.
  • Memory: the cache is bounded by the number of distinct (field, object type) pairs in the operation and lives only as long as the ExecutionContext.
  • API surface: this adds a field to ExecutionContext plus one @Internal getter, getCollectedSubFields(). Happy to move it elsewhere if you prefer.

Benchmarks

Apple Silicon, JDK 21. Base vs this branch jars were run interleaved (alternating, 3 rounds × 2 forks each = 6 forks per side) to cancel out machine drift. Δ shows the 95% confidence interval.

Benchmark Throughput Δ Allocated bytes/op
ExecutionBenchmark.benchmarkThroughput +14.7% ± 7.0% 1,073,041 → 903,689 (−15.8%)
SimpleQueryBenchmark.benchMarkSimpleQueriesThroughput +10.2% ± 9.8% 1,933,273 → 1,510,351 (−21.9%)
IntrospectionBenchmark (large-schema-2) +19.4% ± 9.0% 112.3M → 98.2M (−12.6%)¹

An earlier run on a quieter machine of the same change (before a cosmetic computeIfAbsent refactor) gave tighter intervals: ExecutionBenchmark +9.3% ± 0.8%, SimpleQuery +15.0% ± 1.1%, Introspection +18.4% ± 8.1%. An A/A run (base vs base) on that machine showed no significant difference, with a noise floor of about ±1.8% on ExecutionBenchmark.

¹ Introspection bytes/op vary between forks even for the same jar (96.9M vs 114.5M observed for base), so treat that row as indicative only.

Tests

New CollectedSubFieldsReuseTest covers:

  • objects of different concrete types in the same interface list each get their own type's fields
  • aliased fields with different sub selections are collected separately
  • fields merged across fragments keep all their sub selections
  • collection happens once per (field, object type)
  • a non-deterministic ConditionalNodeDecision is still asked for every object, so the cache is bypassed
  • the cache is bypassed when incremental support is enabled

To check the tests guard the fallbacks, I temporarily removed the bypass condition: the ConditionalNodeDecision test then returned wrong data and the incremental test failed. The full test suite passes, apart from the 14 locale-dependent StringValueParsingUnicodeTest failures that also occur on master with a non-English JVM locale (fixed separately in #4500).

🤖 Generated with Claude Code

completeValueForObject collected the sub fields of the current field
for every object in the result, so a list of N objects repeated the
same selection set walk, @skip/@include evaluation and fragment
expansion N times. The result only depends on the merged field and
the object type, since variables and fragments are fixed for an
execution, so it is now memoized per execution in ExecutionContext.

The cache is bypassed when incremental support is enabled (deferred
executions are created per collection) or when a custom
ConditionalNodeDecision is present (it may not be deterministic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Meemaw
Meemaw marked this pull request as ready for review October 18, 2026 17:45

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant