Commit graph

2 commits

Author SHA1 Message Date
Kamron Batman
9acd701aaa
perf(saves): workers iterate entity dictionaries directly; main thread joins the drain
Removes the per-entity handoff from the freeze entirely. GenericEntityPersistence
publishes 4096-slot ranges over its dictionary's backing entries array, and workers
serialize occupied slots (value != null) directly via a ShadowEntry<TValue> struct
that mirrors the runtime's private Dictionary Entry layout. Safe because the
dictionary is frozen during Saving (mutations divert to the pending safety queues).

The layout is proven at startup before any code reads through it: validation
measures the true Entry stride via precise allocation accounting (so all shadow
reads are guaranteed in-bounds), then compares every key and value of a churned,
resized, freelist-exercised dictionary reading value slots as raw pointer bits
only - never materializing a managed reference until the layout is proven. If a
future runtime changes Dictionary internals, validation fails and saves fall back
to the enumerate-and-push path with a logged warning.

The main thread now joins the drain through an inline (threadless) worker after
publishing work, instead of idling while the thread workers finish - worth a full
worker share on the freeze and proportionally more on low-core hosts.

Measured through the real pipeline classes (24 cores, 10M entities, 1.7GB, dense
2-byte write profile): publish cost drops from ~55ms to ~0.1ms, the freeze is now
bound by pure serialize throughput at ~99ms steady state (vs ~740ms before this
branch, ~7.5x), and the first save after boot drops from ~468ms to ~139ms because
fine-grained ranges self-balance without needing size estimates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:38:46 -07:00
Kamron Batman
230c39851f
perf(saves): chunked worker handoff, LPT blob scheduling, heap pre-sizing
Replaces the per-entity round-robin ConcurrentQueue handoff between the game
loop and the serialization workers with pooled 4096-entity chunks published to
a single shared queue. The producer's per-entity cost drops from a synchronized
enqueue to a plain array store, and workers pulling whole chunks load-balance
dynamically: a worker busy with a thick entity simply takes fewer chunks.

Scheduling changes so indivisible multi-megabyte payloads no longer extend the
freeze tail:
- Persistence.SerializeAll pushes systems largest-first (LPT), using the
  previous save's SerializedLength (or the loaded file size on first save).
- Persistence self-payloads and entities whose previous size exceeds 1MB are
  published as dedicated single-entity chunks so they spread across workers
  instead of riding inside one shared chunk.
- Entity SerializedLength is stamped from the index at load so estimates exist
  on the first save after boot.

Worker heaps are pre-sized from the loaded save's .bin sizes (25% headroom) so
the first save doesn't pay copy-on-grow inside the freeze, and the drain loop
uses SpinWait backoff (never Sleep(1)) instead of hammering the queue head
while the producer works. Snapshot writing uses a 1MB FileStream buffer, and
per-worker entity/byte counts are logged at Debug for balance diagnostics.

Measured on a 24-core machine with a synthetic 10M-entity, 1.7GB world
(64/64/32MB system payloads, 24x 2MB thick entities) through the real
pipeline classes: freeze window 740ms -> 122-160ms steady state (~5-6x),
first save 490ms -> 330ms, steady-state allocations converge to zero, and
worker byte loads converge (previous max/min spread ~2x -> ~1.15x for the
small-entity stream).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:38:46 -07:00