perf(skymp5-server): parallel area offload and interest management - #2789
Open
reggiedroid wants to merge 1 commit into
Open
perf(skymp5-server): parallel area offload and interest management#2789reggiedroid wants to merge 1 commit into
reggiedroid wants to merge 1 commit into
Conversation
reggiedroid
marked this pull request as ready for review
August 4, 2026 20:07
Movement is the highest-volume packet and its cost is quadratic: each update is
relayed to everyone who can see the sender, so N players in one area cost on the
order of N^2 relay decisions per tick, all on the single Node thread that drives
ScampServer::Tick.
This adds two opt-in mechanisms and one unconditional fix. With
parallelism.enabled absent or false, the original code path runs byte for byte.
Interest management (on by default when the feature is enabled)
---------------------------------------------------------------
Recipients within interestFullRateUnits (2048, about half a chunk) always
receive every update. Beyond that the rate steps down to a half, a third, a
quarter, capped by maxInterestSkipTicks; at a 60Hz tick a distant player still
gets ~15 updates a second. Each pair's phase comes from a hash of the pair, so
traffic spreads across the window instead of bursting, and the suite asserts
every pair still transmits exactly once per window.
Measured, 400 players spread across one chunk:
inline (baseline) 160000 relays 1233us
offload only 160000 relays 1048us
offload + interest management 119866 relays 890us (1.39x)
Parallel area offload (opt-in)
------------------------------
Relay decisions move to worker threads. Actors are partitioned into area
clusters that provably cannot influence one another -- Grid.h uses a 3x3 chunk
stencil and movement validation caps an update just under one chunk, so reach is
at most 2 and a separation of 3 contains every recipient. Clusters are then
sharded, because on a realistic population one hub holds most of a tick's work
and a cluster-sized task would hand that block to a single core.
Recipients come from an O(N) per-tick snapshot of active player positions that
workers filter spatially, rather than enumerating O(N^2) edges on the main
thread. Workers touch only the immutable snapshot and their own output buffer,
so the parallel phase needs no locks.
players 25 50 100 150 250 400
speedup 0.25x 0.43x 0.66x 0.81x 0.88x 1.10x
Break-even is near 300-400 players, so minActorsToOffload defaults to 300.
Below that the offload is a regression and should stay off. The parallel phase
itself scales well -- at 400 players it retires 966us of task work in 96us of
wall clock -- but the join emits 160k relays serially for 787us of an 1118us
tick even with a no-op send target, which is why sending less matters more than
deciding faster.
Determinism is preserved: clusters ordered by lowest chunk coordinate, members
by submission index, shards contiguous slices of that order, join walks work
units by index. Same sequence of world writes on 2 cores or 32.
JSON deserialization (unconditional)
------------------------------------
MessageSerializer::Deserialize walked the whole deserializer table for JSON
messages, and each candidate allocated a std::string and constructed a fresh
simdjson::dom::parser before re-parsing the entire document, until one matched.
It now reads the message type once and dispatches directly, reuses a
thread_local parser, and only materializes the message text when trace logging
is enabled. Measured 3.4-6.3x cheaper on that path; addresses TODO(skyrim-multiplayer#2257).
Testing
-------
480346 assertions in 310 cases, 12/12 ctest.
unit/ParallelBenchmark.cpp produces the numbers above and is hidden behind
Catch2's "[.]" tag so ctest ignores it: ./unit/unit "[ParallelBench]".
unit/PartOne_MovementParallelTest.cpp drives the real PartOne, packet parser and
send target with the offload enabled and asserts the same messages reach the
same users as the inline path. The partitioner's safety property is additionally
property-tested over 400 randomized populations (23810 actors).
Two behavioural differences when enabled, both documented in
docs/docs_parallel_area_offload.md: movement relays batch to the end of the tick
rather than being emitted mid-ingest, and a world/cell form id from an unloaded
plugin is rejected with a teleport correction instead of throwing out of the
packet handler.
Not yet load-tested with real clients: the benchmark drives the server directly
with a no-op send target, so it measures server-side relay cost rather than the
network stack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reggiedroid
force-pushed
the
upstream-pr
branch
from
August 4, 2026 20:33
13bded1 to
8a84a1e
Compare
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.
perf(skymp5-server): parallel area offload + interest management
Two changes to how movement relays are handled at scale, both opt-in. With
parallelism.enabledabsent or false the original code path runs unchanged.Every performance number below is measured by
unit/ParallelBenchmark.cpp,included in this branch. Run it yourself:
./unit/unit "[ParallelBench]"The problem
Movement is the highest-volume packet, and its cost is not linear in player
count: each update is relayed to everyone who can see the sender, so N
players in one area cost on the order of N² relay decisions per tick. All of
it runs on the single Node thread that drives
ScampServer::Tick.What was measured
Every player in one chunk, everyone sending movement every tick, over the
binary wire format a real client uses. Timing covers full packet ingest plus
PartOne::Tick, so the inline and offloaded paths are compared over the sameunit of work — the inline path relays during ingest, the offloaded path defers
relays to the join.
Break-even is around 300–400 players. Below that the offload is a
regression, because the barrier and snapshot costs are paid every tick while
the parallel phase is still small.
minActorsToOffloadtherefore defaults to300.
The parallel phase itself scales fine — at 400 players it retires 966µs of task
work in 96µs of wall clock, roughly 10×. The ceiling is elsewhere: the join
emits 160,000 relays serially and costs 787µs of an 1118µs tick, even with a
send target that does nothing. Parallelising decisions cannot fix that.
Interest management
Since relay volume is the quadratic term and emitting the sends is serial
regardless, the higher-value lever is sending less — and it helps at every
population, not only above 300.
Recipients closer than
interestFullRateUnits(2048, about half a chunk)always receive every update, so anything a player is realistically fighting,
trading with or watching stays at full fidelity. Beyond that the rate steps
down to a half, a third, a quarter, capped by
maxInterestSkipTicks. At a 60Hztick a distant player still gets roughly 15 updates a second.
400 players spread across one chunk:
1.39× against the inline baseline, with a send target that does nothing.
With real RakNet sends each avoided relay also skips serialization and
queueing, so the gap widens.
It stacks with
adaptiveThrottlingby taking the stronger factor rather thanmultiplying, so an overloaded server never starves a distant player past
whichever cap is more conservative. Each pair's phase is derived from a hash of
the pair, so traffic spreads across the window instead of bursting — and the
suite asserts every pair still transmits exactly once per window: reduced rate,
never silence.
How the offload works
Per tick:
into a plain-data snapshot instead of relayed immediately.
provably cannot influence one another this tick, then sliced into work units.
outbound send list.
fixed order.
Steps 1 and 4 stay serial because they touch
MpActor,WorldState, thePapyrus VM, RakNet and V8. Workers read only the immutable snapshot and write
only their own output buffer, so the parallel phase needs no locks.
Recipients are derived from an O(N) per-tick snapshot of active players that
workers filter spatially, rather than enumerating O(N²) edges on the main
thread. That distinction matters: an earlier revision materialized every relay
edge during ingest and spent more time building the list than the parallel
phase saved.
Why clusters are safe
Grid.huses a 3×3 chunk stencil (reach 1) and movement validation caps asingle update just under one chunk (reach 1), so an actor's influence spans at
most 2 chunks per tick. Clusters are connected components under same
worldOrCell, Chebyshev chunk distance ≤ S, withSclamped up to 3 so everypossible recipient lands in the sender's own cluster. Different worldspaces and
interiors never share a grid, so instanced content separates perfectly.
Verified by property test over 400 randomized populations (23,810 actors):
actors in different clusters are always beyond relay range, actors within range
always share a cluster, and the result is independent of packet arrival order.
Why shards, not clusters
One task per cluster does not work. On a realistic population one hub holds
most of a tick's relay work, so a cluster-sized task hands that block to a
single core. The scheduling unit is therefore a shard: a contiguous slice of
one cluster's members. Each sender's work depends only on the snapshot, so any
split is safe.
Determinism
Clusters are ordered by lowest chunk coordinate, members by submission index,
shards are contiguous slices of that order, and the join walks work units by
index. Same inputs produce the same sequence of world writes on 2 cores or 32,
and whether a cluster ran whole or split sixteen ways.
Also included
JSON deserialization fix.
MessageSerializer::Deserializewalked the wholedeserializer table for JSON messages, and each candidate allocated a
std::stringand constructed a freshsimdjson::dom::parserbeforere-parsing the entire document, until one matched. It now reads the message type
once and dispatches directly, reuses a
thread_localparser, and onlymaterializes the message text when trace logging is enabled. Measured 3.4–6.3×
cheaper on the JSON path. This closes issue #2257. (Closes #2257)
Production clients send binary — a live client produced no JSON packets at all —
so this only helps legacy clients, but it is a hot path either way.
Configuration
Full option list and tuning guidance in
docs/docs_parallel_area_offload.md.Behavioural differences when enabled
mid-ingest. Movement is unreliable and superseded by the next update, so this
is not observable in play, but it is visible in packet captures, and tests
must tick before asserting on
Messages().correction instead of throwing out of the packet handler.
That is the point; set
interestManagement: falseto disable.Testing
[ParallelPool][ParallelPartition][ParallelOffload][ParallelConfig]/[ParallelBalancer][.][ParallelBench]unit/PartOne_MovementParallelTest.cppdrives the realPartOne, packet parserand send target with the offload enabled, asserting the same messages reach the
same users as the inline path.
A race worth calling out
ThreadPool::Runoriginally returned as soon astasksRemaininghit zero. Theworker that ran the final task decrements that counter and only then loops
back to the task cursor — so if
Runreturned in that window and the next tickstarted a batch, the cursor reset would hand the still-draining worker index 0
of the previous task vector. The failure mode is not a duplicated packet: the
straggler decrements a counter it was never part of, underflowing it, and the
barrier never releases — the server tick hangs.
Runnow also waits for every drainer to leave. Verified by reverting only thefix and inserting a 300µs delay where a straggler would sit: that build hangs on
Alternating batch sizes stay consistentin 3 of 3 runs, the fixed build passes3 of 3. The natural window is a few instructions wide, so on an idle machine
that test guards against regression rather than reliably detecting the race.
Honest limitations
PartOnedirectly with a no-op send target, so it measures server-side relay cost, not
the network stack. Real RakNet sends would change the absolute numbers —
probably in interest management's favour, since it avoids that work entirely.
off and why
minActorsToOffloadis 300. On a typical server the useful halfof this PR is interest management.
before tuning.