← 输入框|github.com|刷新
Skip to content

Reduce PerLevelDataLoaderDispatchStrategy bookkeeping: pack per-level counters into one AtomicLong - #4502

Open
Meemaw wants to merge 2 commits into
graphql-java:masterfrom
Meemaw:perf/per-level-dispatch-packed-counters
Open

Meemaw wants to merge 2 commits into
graphql-java:masterfrom
Meemaw:perf/per-level-dispatch-packed-counters

Conversation

@Meemaw

@Meemaw Meemaw commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

What

PerLevelDataLoaderDispatchStrategy.CallStack keeps two counters per level (happenedExecuteObjectCalls, happenedCompletionFinishedCount). Today they live in an immutable StateForLevel behind ConcurrentHashMap<Integer, AtomicReference<StateForLevel>>, so every executeObject and every object completion:

  1. does a computeIfAbsent lookup on the map,
  2. allocates a new StateForLevel,
  3. retries a compare-and-set until it wins, which under concurrent completions on the same level means repeated allocation and retries.

The readiness check (isLevelReady) also reads the two counters with separate get() calls.

This PR stores each level's counters in one AtomicLong: the completion count in the high 32 bits and the execute-object calls in the low 32 bits. The levels sit in an array indexed by level, which grows on demand; query depth bounds its size.

  • Increments: a single getAndIncrement / getAndAdd, with no allocation and no retry loop.
  • Readiness: allExecuteObjectCallsCompleted(level) reads both counters from one snapshot.
  • Hot path: a volatile array read. Only creating a new level, or growing the array, is synchronized.
  • Compatibility: get(level) and tryUpdateLevel(...) keep their signatures, so the existing tests and the subscription/defer paths that use them work unchanged. No public API changes.

Benchmark

PerLevelDispatchOverheadPerformance (new) isolates the strategy's cost. It runs the same wide query (items(250) { a b c d e child { x y grand(5) { v w } } }) with the same data fetchers, none of which use a DataLoader, in two modes:

  • none: no DataLoaderRegistry, so the engine uses the no-op dispatch strategy;
  • perLevel: a registry holding one unused loader, which switches the engine to PerLevelDataLoaderDispatchStrategy.

The difference between the two modes is everything the strategy costs. With async = true, a scalar field on every item completes on a 4-thread pool, so completions for the same level race on the per-level state. That matches servers whose fetchers resume on worker threads.

Results with 5 forks, in µs/op (./gradlew jmh -PjmhInclude=PerLevelDispatchOverheadPerformance, JDK 25, Apple M-series):

fetchers strategy master this PR
async none 795.9 791.8
async perLevel 879.9 ± 9.9 785.8 ± 26.8
async overhead +84.0 (+10.6%) −6.0 (noise)
sync none 582.6 562.1
sync perLevel 601.1 577.3
sync overhead +18.5 +15.2 (unchanged within noise)

With async completion the strategy's overhead disappears; with synchronous fetchers there is no measurable change. Allocation per op is unchanged within noise.

PerLevelDispatchPerformance (also new) is the same query shape with a real DataLoader-backed child field, for an end-to-end view. Its timings were within noise before and after, because the DataLoader dispatch itself dominates there.

I'd welcome a run on your isolated performance environment, since these numbers come from a laptop.

Tests

  • New Spock cases in PerLevelDataLoaderDispatchStrategyTest:
    • level counters beyond the initial array capacity (level 40), the single-snapshot readiness check, and clear();
    • 8 threads × 10k increments across 20 levels with no lost updates.
  • Full suite: ./gradlew test passed locally (5,792 tests).

🤖 Generated with Claude Code

Meemaw and others added 2 commits October 19, 2026 15:23
…tomicLong

CallStack kept each level's two counters in an immutable StateForLevel behind
ConcurrentHashMap<Integer, AtomicReference<StateForLevel>>. Every executeObject
and every object completion did a computeIfAbsent lookup, allocated a new
StateForLevel and retried a compare-and-set until it won, and the readiness
check read the two counters from two separate lookups.

Each level is now one AtomicLong (completion count in the high 32 bits,
execute-object calls in the low 32 bits) in an array indexed by level:
increments are a single getAndAdd, and readiness reads both counters from one
snapshot. get() and tryUpdateLevel() keep their signatures for existing tests.

PerLevelDispatchOverheadPerformance runs the same wide query with no
DataLoaderRegistry (no-op strategy) and with one (per-level strategy); the
difference is the strategy's whole cost. With fetchers completing on a thread
pool (5 forks): +84.0us/op (10.6%) before, -6.0us/op (noise) after. With
synchronous fetchers the overhead is unchanged within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ncurrent increments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Test Report

Test Results

Java Version Total Passed Failed Errors Skipped
Java 11 5984 (+2 🟢) 5928 (+2 🟢) 0 (±0) 0 (±0) 56 (±0)
Java 17 5984 (+2 🟢) 5927 (+2 🟢) 0 (±0) 0 (±0) 57 (±0)
Java 21 5984 (+2 🟢) 5927 (+2 🟢) 0 (±0) 0 (±0) 57 (±0)
Java 25 5984 (+2 🟢) 5927 (+2 🟢) 0 (±0) 0 (±0) 57 (±0)
jcstress 32 (±0) 32 (±0) 0 (±0) 0 (±0) 0 (±0)
Total 23968 (+8 🟢) 23741 (+8 🟢) 0 (±0) 0 (±0) 227 (±0)

Code Coverage (Java 25)

Metric Covered Missed Coverage vs Master
Lines 30169 3120 90.6% ±0.0%
Branches 8852 1520 85.3% ±0.0%
Methods 8056 1207 87.0% ±0.0%

Changed Class Coverage (4 classes)

Class Line Branch Method
g.e.ExecutionStrategy ±0.0% -0.8% 🔴 ±0.0%
g.e.i.d.PerLevelDataLoaderDispatchStrategy +0.7% 🟢 +1.2% 🟢 ±0.0%
g.e.i.d.PerLevelDataLoaderDispatchStrategy
$CallStack
±0.0% +100.0% 🟢 ±0.0%
g.e.i.d.PerLevelDataLoaderDispatchStrategy
$CallStack
$StateForLevel
-26.7% 🔴 ±0.0% -16.7% 🔴
ExecutionStrategy — method details
Method Line Branch
lambda$buildFieldValueMap$0 100.0% 90.0% (-10.0% 🔴)
PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel — method details
Method Line Branch
<init> 0.0% (-100.0% 🔴) —

Full HTML report: build artifact jacoco-html-report

Updated: 2026-10-19 16:06:15 UTC

AtomicReference<StateForLevel> dataPerLevelAtomicReference = stateForLevelMap.computeIfAbsent(level, __ -> new AtomicReference<>(new StateForLevel()));
return Assert.assertNotNull(dataPerLevelAtomicReference.get());
long packed = stateForLevel(level).get();
return new StateForLevel(completionFinishedCount(packed), executeObjectCalls(packed));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still allocating a new StateForLevel object on ever call to get a stastefor level. So the atomic long storage is is more efficient but we are doing object allocation for every call

I don't know how often it gets called however so maybe that is mute

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok so its called twice per level - one on starting the level and once on completing it

So before we would have created a new StateForLevel() via computeIfAbsent on the first time we went to a level and then re-used it when we completed a level - now we allocate two objects.

This might not be so bad -allocating an object that dos not escape out of the stack is cheap (it never gets refernced by long lived code that I can see)

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.

2 participants