Commit graph

6 commits

Author SHA1 Message Date
Kamron Batman
3b75b96008
perf(saves): drop the 9-byte per-entity placement state; write snapshots from worker segment logs
Every ISerializable carried SerializedThread/SerializedPosition/SerializedLength
so WriteSnapshot could gather each entity's bytes from the worker heaps in
dictionary order. The idx records absolute positions, so bin order is free -
the snapshot can be written in worker-heap order instead, and the join inverts:

- Chunks are persistence-homogeneous: SerializeAll declares the owner at each
  boundary, publishing the partial chunk on change.
- Workers log segments (owner, slot range, heap start) plus one length per
  record as they serialize. Positions are implicit because a worker's writes
  are contiguous; identity comes from re-walking the same snapshot slots in
  the same order (guaranteed stable - mutations divert to the pending queues
  until PostWorldSave), or from an entities log on the fallback path.
- WriteSnapshot routes segments by owner, emits idx entries during the
  re-walk, and writes each segment's heap bytes as a single span instead of
  one copy per entity, which also speeds up the background write phase.
- Persistence self-payloads keep placement as three private fields on the
  ~dozens of persistence instances; PushSingle is now typed accordingly.

Net effect: 9 bytes (plus padding) of resident state removed from every item,
mobile, guild, and account on every shard; three interface-property stores
per entity leave the drain hot path (stamping dirtied one cache line per
entity mid-freeze - the lengths log is a single sequential stream); and the
vestigial loader-side length stamp is gone. The transient cost is ~4 bytes
per entity in pooled per-worker logs that are released after each write.

The save format is unchanged (idx v3, same loader); only the write-side
mechanics moved. Adds an end-to-end round-trip test that drives real workers
through the chunk source, snapshots from the segment logs, and reloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:38:47 -07:00
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
Kamron Batman
412a71dfe0
fix(tests): single shared bootstrap; idempotent SerializationThreadWorker.Exit (#2473)
## Problem

Running the full `UOContent.Tests` suite, the test host **hangs ~2.5 minutes at shutdown and then crashes** (`Test host process crashed` / run aborted). The tests themselves are fine — they complete in ~1s — but the process can't exit.

Captured via `--blame-hang` dump. The blocking thread:

```
System.Threading.WaitHandle.WaitOne()
Server.SerializationThreadWorker.Sleep()        SerializationThreadWorker.cs:54  (_stopEvent.WaitOne())
Server.SerializationThreadWorker.Exit()         SerializationThreadWorker.cs:61
Server.World.ExitSerializationThreads()         World.cs:429
Server.Tests.UOContentFixture..ctor()
```

### Root cause

Both collection fixtures (`UOContentFixture` and `PathfindingTestFixture`) each run the **full process-global ModernUO bootstrap**. `World.Load()` is guarded to run once per process, so the **second** fixture's `World.Load()` is a no-op and does **not** respawn the serialization workers — but `World.ExitSerializationThreads()` is **not** guarded, so the second fixture calls `Exit()` on workers whose threads have already terminated. `Exit()` → `Sleep()` → `_stopEvent.WaitOne()` then blocks forever (a dead thread never sets the event). The first collection's tests run; the second collection's fixture deadlocks in its constructor; the host eventually gets killed.

This is why single-collection (filtered) runs were fine — only one fixture ever bootstraps — but the full suite hangs. It's not a parallelization race: even strictly sequential, the second fixture deadlocks.

## Fix

**(a) Engine — idempotent `SerializationThreadWorker.Exit()`**
A second `Exit()` is now a safe no-op instead of a permanent block. Only the owning (main) thread calls `Exit()`, so no synchronization is needed, and the single-call production shutdown path is unchanged.

**(b) Tests — one shared bootstrap, strictly sequential collections**
- New `TestServerBootstrap.EnsureInitialized()` runs the superset global init **exactly once per process** (lock + once-flag).
- `UOContentFixture` / `PathfindingTestFixture` slim down to delegate to it and no longer tear down global state (which the single-bootstrap model owns for the host's lifetime).
- `[assembly: CollectionBehavior(DisableTestParallelization = true)]` so collections never overlap.

## Result

| | Before | After |
|---|---|---|
| Tests run (full suite) | 258 (UOContent collection deadlocked) | **418** |
| Outcome | 2.5-min hang → host crash | **418 passed, clean exit** |
| Wall time | killed | **~7s** |
2026-06-07 12:36:37 -07:00
Kamron Batman
e1e1a7c640
fix: Bumps deps. Updates copyrights (#2353) 2026-03-05 19:36:54 -08:00
Kamron Batman
465d3c8187
feat: Upgrades serialization v4 (Threaded Heap Serialization) (#1947)
### Summary

- `GenericEntityPersistence` is now a type of `GenericPersistence`. This allows developers to serialize both entities and non-entities in the same system. 🎉
- Each `SerializationThreadWorker` now allocates 1MB of heap for serialization _permanently_. If more memory is needed, that thread will double it's memory, not to exceed increments of 64MB.
- Several bugs with serialization introduced with the pure MMF implementation have been fixed.
- `BinaryFileReader` has been added back. 🎉
- Adds `world.useMultithreadedSaves` to allow disabling threaded saves.

> [!IMPORTANT]
> **Developer Note**
> The split file serialization has been deprecated and is no longer used. We have effectively gone back to the same file writing we had before the pure MMF implementation.
2024-09-14 09:57:43 -07:00