feat(pathfinding): lazy .swb backing store for the step cache (#2448)
## Summary Adds a binary disk format + lazy reader so the step cache can warm-start from a precomputed file without paying chunk-build cost on the first pathfind through a region. **Resident memory stays bounded by `MaxResidentChunks` regardless of file size** — opening a `.swb` reads only the header + chunk-offset index (~16 bytes per indexed chunk), and individual chunks are seeked + deserialized only when `ResolveMissingChunk` asks for them. The lazy design (vs. an eager bulk load): a 250 MB bake on a RAM-constrained shard never materializes more than the LRU cap (~40 MB at the default 8192-chunk cap), and unwanted regions never enter memory at all. Builds on PR #2447. ## What changed - **`StepCacheFile`** — binary reader/writer module. Writer emits header → chunks (offsets recorded) → index trailer, then patches the header's `IndexOffset` field. Reader is `OpenForLazy(path)` returning a `LazyReader` that holds an open `FileStream` + offset dictionary. - **`StepCacheFile.LazyReader`** — `TryReadChunk(chunkX, chunkY)` does a single seek + bulk read for one record. `Dispose` releases the underlying stream. Files are opened with `FileShare.Read | FileShare.Delete` so admin tooling can replace them. - **TileData fingerprint via XxHash3.** The `.swb` header carries a hash of `LandTable + ItemTable` flags. Load rejects any file whose hash doesn't match the running server. Computed via `HashUtility.ComputeHash64` (engine-blessed hasher) — adds a `ReadOnlySpan<byte>` overload alongside the existing `ReadOnlySpan<char>` one for parity. - **`StepCache.SaveToFile(path, mapId)`** — writes resident chunks for the given map. - **`StepCache.TryOpenLazyReader(path, mapId)`** — opens the file, validates header, holds the reader for the map's lifetime. - **`StepCache.ResolveMissingChunk`** — now consults the lazy reader before invoking the runtime baker. A loaded chunk whose `BuiltMultisVersion` doesn't match the live sector falls through to the baker (snapshot was made before a multi was added/removed in that sector). - **`StepCache.Clear` closes lazy readers.** Test cleanup can delete `.swb` files cleanly. - **Auto-load at startup.** `PathCacheCommands.Configure()` opens `Data/Pathfinding/<mapId>.swb` as a lazy reader for every map. - **`[PathCacheSave`** / **`[PathCacheLoad`** — admin commands for the same workflow. - **`pathfinding.maxResidentChunks` shard-tunable.** Read from `server.cfg` at boot via `ServerConfiguration.GetOrUpdateSetting` (default 8192 ≈ 40 MB). Small shards can tune down; large shards with substantial bakes can tune up to reduce eviction churn. Default is written back to `server.cfg` on first boot, matching the engine pattern used by other settings. ## File layout (v1) ``` Header (48 bytes): u32 Magic = 0x42575300 ('SWB\0') u32 Version = 1 u32 MapId u64 TileDataHash XxHash3 over LandTable + ItemTable flags (HashUtility) u64 BakeTimestamp informational u32 ChunkCount u64 IndexOffset file position where the chunk index begins Chunk records (fixed size, ~5,393 bytes each, +32 if multi-Z): u16 ChunkX u16 ChunkY u32 BuiltMultisVersion u8 HasMultiZ byte WalkMask[256], WetMask[256] sbyte SourceZ[256], WalkZN..WalkZNW[256], SwimZN..SwimZNW[256] [byte MultiZCells[32] when HasMultiZ == 1] Index trailer (16 × ChunkCount bytes): (u64 chunkKey, u64 fileOffset) ``` ## Memory math | Scenario | Disk file | RAM at boot | Notes | |---|---|---|---| | Empty / no `.swb` files | — | 0 | Silent; cache builds on demand. | | Admin-curated towns (5K chunks) | 25 MB | 0 + per-query | Index ≈ 80 KB. Resident grows to the configured cap under steady-state queries. | | Full-map bake (50K chunks) | 250 MB | 0 + per-query | Index ≈ 800 KB. Same configured cap. Cold areas never load. | | All 5 maps fully baked | 1.25 GB | 0 + per-query | Index ≈ 4 MB total. Same configured cap. | ## Hash choice (FNV-1a → XxHash3) The original draft used inlined FNV-1a-64. Switched to XxHash3 via `HashUtility`: - ~30× faster on this workload (~30 GB/s SIMD vs ~2 GB/s byte-by-byte). Boot-time only, so absolute saving is microseconds — the real wins are elsewhere. - Stronger collision resistance and distribution. - Drops ~25 lines of inlined hash code; matches the rest of the codebase's hashing pattern. - Hash is stable as long as `HashUtility`'s `xxHash3Seed` constant doesn't change (already marked `// DO NOT CHANGE THIS NUMBER`).
This commit is contained in:
parent
9066e8fd00
commit
7c9215d97c
6 changed files with 861 additions and 4 deletions
|
|
@ -67,6 +67,7 @@ public sealed class StepCache
|
|||
{
|
||||
_chunks.Clear();
|
||||
_keysList.Clear();
|
||||
CloseLazyReaders();
|
||||
_hits = 0;
|
||||
_missesNotBuilt = 0;
|
||||
_missesDirtyRebuild = 0;
|
||||
|
|
@ -77,6 +78,111 @@ public sealed class StepCache
|
|||
_buildsTotal = 0;
|
||||
}
|
||||
|
||||
// Per-map open .swb readers, populated by TryOpenLazyReader at startup. Chunks are
|
||||
// fetched on demand from the file when ResolveMissingChunk fires; resident memory
|
||||
// stays bounded by MaxResidentChunks regardless of file size.
|
||||
private readonly Dictionary<int, StepCacheFile.LazyReader> _lazyReaders = new();
|
||||
|
||||
/// <summary>
|
||||
/// Persist all resident chunks for <paramref name="mapId"/> to a .swb file. Returns
|
||||
/// the number of chunks written. The file embeds a TileData fingerprint so a stale
|
||||
/// file (built before a client patch) can be detected and rejected at open time.
|
||||
/// </summary>
|
||||
public int SaveToFile(string path, int mapId)
|
||||
{
|
||||
var matching = 0;
|
||||
foreach (var key in _keysList)
|
||||
{
|
||||
DecodeKey(key, out var keyMapId, out _, out _);
|
||||
if (keyMapId == mapId)
|
||||
{
|
||||
matching++;
|
||||
}
|
||||
}
|
||||
|
||||
var enumerator = _keysList.GetEnumerator();
|
||||
StepCacheFile.Write(path, (uint)mapId, (uint)matching, EmitChunk);
|
||||
enumerator.Dispose();
|
||||
return matching;
|
||||
|
||||
bool EmitChunk(out int chunkX, out int chunkY, out StepChunk chunk)
|
||||
{
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
var key = enumerator.Current;
|
||||
DecodeKey(key, out var emittedMapId, out chunkX, out chunkY);
|
||||
if (emittedMapId == mapId)
|
||||
{
|
||||
chunk = _chunks[key];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
chunkX = chunkY = 0;
|
||||
chunk = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a .swb file as a lazy backing store for <paramref name="mapId"/>. Reads only
|
||||
/// header + chunk-offset index (~16 bytes per chunk); individual records are fetched
|
||||
/// on demand by <see cref="ResolveMissingChunk"/>. Returns false on missing file,
|
||||
/// magic / version mismatch, or TileData hash mismatch (stale bake).
|
||||
/// </summary>
|
||||
public bool TryOpenLazyReader(string path, int mapId)
|
||||
{
|
||||
var reader = StepCacheFile.OpenForLazy(path);
|
||||
if (reader == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reader.MapId != (uint)mapId)
|
||||
{
|
||||
logger.Warning(
|
||||
"StepCache: {Path} declares mapId {FileMapId} but caller requested {RequestedMapId}; ignoring",
|
||||
path, reader.MapId, mapId
|
||||
);
|
||||
reader.Dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_lazyReaders.TryGetValue(mapId, out var existing))
|
||||
{
|
||||
existing.Dispose();
|
||||
}
|
||||
_lazyReaders[mapId] = reader;
|
||||
|
||||
logger.Information(
|
||||
"StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}",
|
||||
path, reader.IndexedChunkCount, mapId
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of .swb readers currently open. Mostly for tests / telemetry.
|
||||
/// </summary>
|
||||
public int OpenLazyReaderCount => _lazyReaders.Count;
|
||||
|
||||
/// <summary>Test-only diagnostic: does the lazy reader for <paramref name="mapId"/> hold an offset for (chunkX, chunkY)?</summary>
|
||||
internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) =>
|
||||
_lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY);
|
||||
|
||||
/// <summary>
|
||||
/// Closes all open lazy readers, releasing their underlying file streams. Called from
|
||||
/// <see cref="Clear"/> so test cleanup can delete .swb files (they're held with
|
||||
/// FileShare.Read | FileShare.Delete, so this is mostly belt-and-suspenders).
|
||||
/// </summary>
|
||||
public void CloseLazyReaders()
|
||||
{
|
||||
foreach (var reader in _lazyReaders.Values)
|
||||
{
|
||||
reader.Dispose();
|
||||
}
|
||||
_lazyReaders.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probabilistic LRU sample size — picks SampleSize random resident chunks per
|
||||
/// eviction and evicts the oldest of that sample. Approximates true LRU at a tiny
|
||||
|
|
@ -230,10 +336,29 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chunk-miss resolution: build the chunk via the runtime baker.
|
||||
/// Chunk-miss resolution: try the lazy file reader for this map first; if there's no
|
||||
/// file or no record at this (chunkX, chunkY), fall back to the runtime baker. The
|
||||
/// file path validates each loaded chunk's MultisVersion against the live sector — a
|
||||
/// stale snapshot triggers a rebuild rather than serving a wrong answer.
|
||||
/// </summary>
|
||||
private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) =>
|
||||
BuildChunk(map, chunkX, chunkY);
|
||||
private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY)
|
||||
{
|
||||
if (_lazyReaders.TryGetValue(map.MapID, out var reader))
|
||||
{
|
||||
var loaded = reader.TryReadChunk(chunkX, chunkY);
|
||||
if (loaded != null)
|
||||
{
|
||||
var sector = map.GetRealSector(chunkX, chunkY);
|
||||
if (loaded.BuiltMultisVersion == sector.MultisVersion)
|
||||
{
|
||||
return loaded;
|
||||
}
|
||||
// Snapshot is stale (multis added/removed since the bake). Fall through
|
||||
// to the runtime baker; a future SaveToFile will overwrite the entry.
|
||||
}
|
||||
}
|
||||
return BuildChunk(map, chunkX, chunkY);
|
||||
}
|
||||
|
||||
private StepChunk BuildChunk(Map map, int chunkX, int chunkY)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue