perf(network): stop shuffling the whole connection set on every routed batch - #873
Draft
1linkovdim wants to merge 2 commits into
Draft
perf(network): stop shuffling the whole connection set on every routed batch#8731linkovdim wants to merge 2 commits into
1linkovdim wants to merge 2 commits into
Conversation
RoundRobinRouter.route() copied the connection set into an ArrayList and
Collections.shuffle()'d it on every call, only to then walk it in order.
Three consequences, all paid per call:
- Collections.shuffle(List) draws from a single private static Random
owned by java.util.Collections and shared by the entire JVM
(Collections.java, `private static Random r`). Random.nextInt is a CAS
loop on one seed word, so the shuffle performs n contended CAS
operations, contended not just across router threads but against every
other caller of shuffle() in the process.
- the ArrayList copy allocates an n-slot array, on top of the fresh
HashSet that ConnectionManager.connections() and
ConnectionGroup.getConnections() already allocate per call.
- n element swaps whose only observable effect is which connection the
round robin happens to start on.
Only that start offset is observable, so take it directly: one
ThreadLocalRandom draw, then advance the looping iterator over the set
that was already handed in. Same uniform expectation per connection, no
shared seed, no allocation, and on average half the element visits.
This is per subscriber group, not per drain -- GroupChunkProcessor calls
route() once for each group with the same chunk list -- so it multiplies
by the group count.
Two allocation fixes in the same path while here:
- the per-destination buffer was a LinkedList, one Node object per
event held purely to reference a byte[] an array slot would hold for
free. Now a pre-sized ArrayList, matching ConsistentHashingRouter.
- the writes map was unsized and rehashed past 12 entries. Sized to
min(numConnections, chunks.size()): a batch cannot reach more
destinations than it has chunks, and sizing on numConnections alone
would allocate a million-entry table for a large fan-out group.
The empty-batch and no-connection guards moved to the top as early
returns, which is only hygiene -- TimedChunker.drain() and
SingleThreadedChunker.drain() both check for a non-empty buffer before
calling the processor, so chunks is never empty here in practice.
Behaviour is unchanged, including the pre-existing rule that a chunk
rejected by its connection's predicate is dropped rather than offered to
the next connection. RoundRobinRouterTest pins that plus batching,
distribution, and the start-offset randomisation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mark This repo had no JMH module. The plugin is applied module-locally, mirroring how other Netflix mantis modules do it, so the root build is untouched. RoundRobinRouterBenchmark compares route() against LegacyRoundRobinRouter, which holds the pre-change route() and loopingIterator() bodies copied verbatim out of git rather than reimplemented -- the comparison is only worth anything if the baseline cannot drift under review, so that file is explicitly marked do-not-clean-up. Deliveries are counted in a plain field on a per-connection sink rather than via Counter: CounterImpl.value() reads through to a no-op Spectator registry outside a running worker, so it always reads 0 here. Thread count is the whole point of this benchmark -- the largest cost removed is the contended CAS on Collections' JVM-wide private static Random -- and -t is not expressible as a @PARAM, so the results in the PR description come from driving the built jmhJar at -t 1 and -t 8.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
RoundRobinRouter.route()no longer copies and shuffles the connection set on every routed batch. It takes a single random start offset instead, and stops using aLinkedListper destination.Why
route()is on the push server's fan-out path, once per subscriber group per drain —GroupChunkProcessor.processloops the groups and callsroute()for each with the same chunk list, so everything below multiplies by the group count, once per drain.The old body was:
and then walked
randomOrderin order via a looping iterator. Three costs, all per call, and only one bit of the result is ever observed:1.
Collections.shuffle(List)draws from oneRandomshared by the whole JVM. Fromjava.util.Collections:Random.nextIntis a CAS loop on a single seed word. So a shuffle of n connections is n contended CAS operations on one shared location — contended across every router thread, and against every other caller ofCollections.shuffleanywhere in the process. This is the part that does not scale with core count.2. The
ArrayListcopy allocates an n-slot array, on top of the freshHashSetthatConnectionManager.connections()/ConnectionGroup.getConnections()already allocate per call. It exists only so the shuffle has something mutable to swap in.3. n swaps whose only observable effect is which connection the round robin starts at.
Since the start offset is the only observable output, take it directly:
Same uniform expectation per connection, one RNG draw instead of n, no shared seed, no allocation, and on average half the element visits. The looping iterator already wraps, so nothing else changes.
Note what this does not fix: advancing to a random offset is still O(n) in the worst case, so a group with a very large connection count still walks a large fraction of the set per drain. Making that O(chunks) needs a start cursor that persists across calls, which is a behaviour change (the current code re-randomises every drain) and is better argued separately. Left out deliberately.
Two allocation fixes in the same loop
new LinkedList<>()new ArrayList<>((chunks.size() / numConnections) + 1)writesmapnew HashMap<>()new HashMap<>(Math.min(numConnections, chunks.size()))The
LinkedListallocated oneNodeper event purely to hold abyte[]reference that an array slot holds for free — garbage on a per-batch hot path, with no purpose. The capacity expression is the same oneConsistentHashingRouteralready uses.The map sizing is deliberately not
ConsistentHashingRouter'snew HashMap<>(numConnections). A batch cannot reach more destinations than it has chunks, sonumConnectionsalone would allocate a table sized to the whole fan-out — for a group with ~1e6 connections and a ~30-event batch, a million-entry table perroute()call.min(numConnections, chunks.size())is the correct bound. Worth a look at whether CHR should get the same clamp; not touched here.Hygiene, not a saving
The empty-chunks and empty-connections checks moved to the top as early returns, so the router no longer does setup work for a batch it will not route. This reads like a win but isn't one in practice:
TimedChunker.drain()andSingleThreadedChunker.drain()both check for a non-empty buffer before callingprocessor.process, sochunksis never empty by the time it reachesroute(). Calling it out so nobody counts it twice.Behaviour
Unchanged, including one pre-existing rule that is easy to misread as a bug: a chunk assigned to a connection whose predicate rejects it is dropped, not offered to the next connection. That is what the old code did and this change preserves it.
loopingIteratornow takes theSetdirectly rather than the removedArrayListcopy. That is safe because bothConnectionManager.connections()andConnectionGroup.getConnections()return a freshly builtHashSetthat no one else holds — the old code was copying a copy.Testing
New
RoundRobinRouterTest— 7 tests, pinning the contract rather than the internals:chunks.size()connections are written to./gradlew :mantis-network:testis green (JDK 17). Counter assertions were removed from the tests on purpose — the default Spectator registry in this module is a no-op registry, soCounter.value()always reads 0 and asserting on it measures nothing.Benchmarks
This repo had no JMH source set, so this PR adds one to
mantis-network(src/jmh,me.champeau.jmhapplied module-locally so the root build is untouched) plusRoundRobinRouterBenchmark.The baseline arm is
LegacyRoundRobinRouter, which holds the verbatim pre-changeroute()andloopingIterator()bodies, copied out of git rather than reimplemented:so the two arms cannot drift apart under review.
@Setupcross-checks that both deliver exactlychunkSizeevents before anything is measured. Deliveries are counted in a plain field on a per-connection sink object, not viaCounter—CounterImpl.value()reads through to a no-op Spectator registry outside a running worker, so it would always read 0.JDK 17.0.17-zulu,
@Fork(1), 5x1s warmup, 5x1s measurement,Mode.AverageTime. Thread count is not expressible as a@Param, so both runs are driven from the built jar:Single-threaded (
-t 1) - the allocation and O(n) work onlyThe gap widens with connection count and not with chunk count, which is what the analysis predicts: the removed work is O(connections) per call, and the per-chunk loop is unchanged apart from
LinkedList->ArrayList.8 threads (
-t 8) - the shared-RandomCASRead those numbers with the right caveats:
route()with zero contention assumed, which holds regardless of deployment.Counterincrements and sink cache lines, present in both arms.