Skip to content

Feat/parallel area offload - #2790

Closed
reggiedroid wants to merge 8 commits into
skyrim-multiplayer:mainfrom
reggiedroid:feat/parallel-area-offload
Closed

Feat/parallel area offload#2790
reggiedroid wants to merge 8 commits into
skyrim-multiplayer:mainfrom
reggiedroid:feat/parallel-area-offload

Conversation

@reggiedroid

Copy link
Copy Markdown

No description provided.

reggiedroid and others added 8 commits August 3, 2026 23:23
Skyrim is effectively single-threaded and so was the server: one Node
thread drives ScampServer::Tick, pumping every packet and running every
handler in sequence. Movement is the dominant packet by volume 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 ~N^2 relay
decisions per tick. That quadratic term is what makes crowded areas
choppy while the host machine sits mostly idle.

This adds an opt-in framework that spreads that work across the cores the
host actually has. Per tick the server now:

  1. ingests packets on the main thread as before, but flattens each
     update into a plain-data snapshot instead of relaying immediately;
  2. partitions actors into area clusters that provably cannot influence
     one another this tick, then slices each cluster into work units;
  3. processes each unit on a worker thread (validate, cull, build the
     outbound send list);
  4. joins on the main thread, applying world state and handing packets
     to the network in a fixed, reproducible order.

Steps 1 and 4 stay serial because they touch MpActor, WorldState, the
Papyrus VM, RakNet and V8. Workers read only the immutable snapshot and
write only their own output buffer, so the parallel phase needs no locks.

Cluster safety follows from the game's own visibility rule: Grid.h uses a
3x3 chunk stencil (reach 1) and movement validation caps one update just
under a chunk (reach 1), so an actor's influence spans at most 2 chunks.
Clusters are connected components under "same worldOrCell, Chebyshev
chunk distance <= S", and S is clamped up to 3 so every possible
recipient lands in the sender's own cluster.

Clusters alone are not enough. On a realistic population one hub holds
~70% of a tick's relay work at every size from 40 to 300 players, which
caps a cluster-per-task scheme at ~1.4x regardless of core count -- the
framework would help least exactly where the server hurts most. The unit
of scheduling 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, and modelled speedup rises to ~4x/8x/15x on 4/8/16 cores.

Determinism is preserved: 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. The same inputs
produce the same sequence of world writes on 2 cores or 32.

Under measured overload, distant relays inside a hot cluster are spaced
across several ticks; players close to each other are never throttled.

Off by default. With parallelism.enabled absent or false the original
code path runs unchanged.

Two behavioural differences when enabled, both documented:
  - movement relays batch to the end of the tick rather than being
    emitted mid-ingest (unobservable in play, visible in packet captures,
    and tests must tick before asserting);
  - a world/cell form id from an unloaded plugin is rejected with a
    teleport correction instead of throwing out of the packet handler.

Tests: unit/Parallel{ThreadPool,Partitioner,Offload,Config}Test.cpp cover
the pool, the partitioner's safety property, the dispatcher and the
config. 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Removed the redundant SIMDJSON parser instantiation inside each deserializer.
- Passed the pre-parsed `simdjson::dom::element` directly from the factory down to the specific message deserializers.
- This eliminates heavy string allocations and parsing overhead per message, substantially speeding up ingest on legacy JSON clients.
…est management

The speedups previously claimed for the parallel area offload were modelled
from a relay-edge cost model, never measured. They were wrong. This adds a
benchmark, corrects the record, and adds the optimisation that actually pays.

unit/ParallelBenchmark.cpp times the inline path against the offloaded one
with every player in a single chunk, over the binary wire format a real
client uses, counting full ingest plus PartOne::Tick so both paths cover the
same unit of work. Hidden behind Catch2's "[.]" tag so ctest ignores it:

    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 now defaults to 300
instead of 24. At the old default the feature was a regression on every
realistic server, since SkyMP's own maxPlayers default is 100.

The parallel phase itself is fine -- at 400 players it retires 966us of task
work in 96us of wall clock. The ceiling is the join, which emits 160k relays
serially for 787us of an 1118us tick even with a send target that does
nothing. Parallelising decisions cannot help that. Sending fewer can.

So this adds distance-based interest management, on by default and
independent of load. Recipients within interestFullRateUnits (2048, about
half a chunk) always get every update; beyond that the rate steps down to a
half, a third, a quarter, capped by maxInterestSkipTicks. At 60Hz a distant
player still receives ~15 updates a second. Measured on 400 players spread
across a chunk:

    inline (baseline)                160000 relays   1233us
    offload only                     160000 relays   1048us
    offload + interest management    119866 relays    890us   (1.39x)

It stacks with adaptiveThrottling by taking the stronger factor rather than
multiplying, so an overloaded server never starves a distant player past
whichever cap is more conservative. Phases are derived from a hash of the
(sender, recipient) 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.

Also in this change:

- MessageSerializer::Deserialize no longer walks the whole deserializer table
  for JSON, re-parsing the document once per candidate. It reads the type
  once and dispatches, reuses a thread_local simdjson parser instead of
  constructing one per message, and only materializes the message text when
  trace logging is actually enabled. Measured 3.4-6.3x cheaper on the legacy
  JSON path (addresses the TODO(skyrim-multiplayer#2257) that lived there). Production clients
  send binary, so this only helps legacy ones, but it is a hot path either
  way.

- Repaired six [ParallelOffload] tests left asserting zero relays after the
  snapshot rework: they built target vectors that were never handed to the
  dispatcher. Recipients now come from SetPotentialTargets, so the tests wire
  that up, and the crowded-area case asserts the full 60x60 fan-out rather
  than a stale count.

- Deserialize takes the parsed element by pointer to a named local rather
  than the address of an rvalue-qualified accessor's result.

Full suite green: 480346 assertions in 310 cases, 12/12 ctest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Written against the benchmark rather than the earlier cost model, so the
numbers here match what unit/ParallelBenchmark.cpp prints. Records the
break-even population, the interest-management figures, and the limitations
that still stand -- notably that the offload is a regression below ~300
players and that nothing here has been load-tested with real clients yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Names the effort (SkyMP: MOP - Multiplayer Optimization Project) and states
what it does, what it measures, and what it does not claim. Kept as its own
file rather than rewriting README.md, which belongs to the parent project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@reggiedroid

Copy link
Copy Markdown
Author

Closing in favor of #2789 which contains the squashed commit and full PR description.

@reggiedroid reggiedroid closed this Aug 4, 2026
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