Repository navigation
Reduce PerLevelDataLoaderDispatchStrategy bookkeeping: pack per-level counters into one AtomicLong - #4502
Reduce PerLevelDataLoaderDispatchStrategy bookkeeping: pack per-level counters into one AtomicLong#4502Meemaw wants to merge 2 commits into
Conversation
…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>
Test ReportTest Results
Code Coverage (Java 25)
Changed Class Coverage (4 classes)
ExecutionStrategy — method details
PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel — method details
|
| 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)); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
What
PerLevelDataLoaderDispatchStrategy.CallStackkeeps two counters per level (happenedExecuteObjectCalls,happenedCompletionFinishedCount). Today they live in an immutableStateForLevelbehindConcurrentHashMap<Integer, AtomicReference<StateForLevel>>, so everyexecuteObjectand every object completion:computeIfAbsentlookup on the map,StateForLevel,The readiness check (
isLevelReady) also reads the two counters with separateget()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.getAndIncrement/getAndAdd, with no allocation and no retry loop.allExecuteObjectCallsCompleted(level)reads both counters from one snapshot.synchronized.get(level)andtryUpdateLevel(...)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: noDataLoaderRegistry, so the engine uses the no-op dispatch strategy;perLevel: a registry holding one unused loader, which switches the engine toPerLevelDataLoaderDispatchStrategy.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):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-backedchildfield, 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
PerLevelDataLoaderDispatchStrategyTest:clear();./gradlew testpassed locally (5,792 tests).🤖 Generated with Claude Code