feat(pathfinding): non-eager TryGetMask + second-touch promotion (#2451)
## Summary Closes the Cold-cache regression flagged in PR #2450. `StepCache.TryGetMask` no longer eagerly runs `BuildChunk` on the first miss for a chunk that isn't in a `.swb` lazy reader. Instead it returns `Fallthrough_NotBuilt` and the caller (`BitmapAStarAlgorithm`) takes the per-cell slow path. The chunk is only promoted to the bitmap fast path after the **second** miss within a 30-second window, filtering single-touch pass-throughs. This makes BitmapAStar's worst-case (cold cache + short hops) collapse from **12–47× slower** than FastAStar to **roughly the same**, which is the floor the slow path can deliver. Steady-state warm performance (the actual deliverable) is unchanged from PR-5 — it was always the cache fast path. ## The pet-follow scenario this fixes A mounted player at ~4 tiles/sec with a pet/hireable following will trigger an NPC pathfind every 100–300 ms. Each pathfind is 1–6 tiles. As the player crosses chunk boundaries (~4 sec/chunk), the pet's first pathfind in the new chunk under the previous behavior triggered a full ~700 µs `BuildChunk` for a chunk the player would leave shortly after. At 50–100 mobiles per shard, this exceeded the 8 ms tick budget. PR-5 BDN data showed scenarios 6–9 (2–8 tile NPC perception) at 2,300–3,700 µs Cold vs FastAStar's 80–200 µs. Under the new gate: - First miss → `Fallthrough_NotBuilt` → caller uses slow path (~30–50 µs short path). No `BuildChunk`. No allocation. - Player keeps moving → chunk never gets a second touch within window → never promoted, no rot. - NPC patrolling a fixed territory → repeatedly hits the same chunks → second touch within window → promote → cache fast path on subsequent calls. ## What changed - **`CacheHitKind.Fallthrough_NotBuilt = 6`** + **`CacheStats.FallthroughNotBuilt`** counter. `IsHit=false`, so the caller routes to slow path. - **`StepCache._chunkMissTracker`** — `Dictionary<long, ChunkMissState>` capped at 4096 entries. State is `(byte missCount, uint lastMissTickStamp)` keyed by chunk key. Window-expired entries reset count to 1; capacity overflow prunes window-old entries first. - **`StepCache.MissPromotionThreshold`** (default `2`) and **`StepCache.MissPromotionWindowMs`** (default `30_000`) — tunable, can be wired through `ServerConfiguration` if shards want different policy. Setting threshold to `1` restores legacy eager-build behavior (used by tests that prime chunks via single `TryGetMask` call). - **`StepCache.TryGetMask` miss branch** — try lazy reader first (file-loaded chunks bypass the tracker entirely; an `.swb` represents an explicit prior decision to keep the chunk warm). Otherwise consult the tracker. - **`BitmapAStarAlgorithm.GetSuccessorsSlowPath`** now layers `IsBlockedByDynamic` on top of `CalcMoves.CheckMovement`. Previously the slow path only ran for `CanFly` creatures and rare cache fallthroughs — `CheckMovement` doesn't iterate same-cell mobiles, so the bitmap fast path's `IsBlockedByDynamic` was the only mobile-blocking check. Now first-touch pathfinds run through the slow path, so the gap had to close. ## Tests 50 pathfinding tests pass (was 47). New / updated: - **`TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough`** — single TryGetMask call returns `Fallthrough_NotBuilt`, no chunk built, no allocation. - **`TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds`** — second call inside the 30s window builds + serves. - **`TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers`** — second call outside the window restarts the count, returns Fallthrough again. - **`TryGetMask_DistinctChunks_TrackedIndependently`** — counters are per-chunk; one touch on each of two adjacent chunks both stay in fallthrough. - **`LazyReaderHit_BypassesMissTrackerOnFirstTouch`** — open `.swb` + first touch hits without consulting the tracker. Production with `.swb` loaded skips the gate entirely. - **`MultisVersion_Bump_TriggersDirtyRebuild`** — updated to reflect the new 3-step flow (Fallthrough → Miss_NotBuilt → Miss_DirtyRebuild). - Tests that prime chunks via a single `TryGetMask` call (multi-Z, Tier4, lifecycle, parity, BitmapAStar uses-cache) set `MissPromotionThreshold = 1` to opt into eager behavior. ## Expected BDN impact The Cold column from PR-5's BDN should change as follows once the bench's submodule pointer is updated to this branch: | # | Scenario | Cold (PR-5) | Cold (PR-6 expected) | FastAStar Cold | |--:|-----------------|-------------:|---------------------:|---------------:| | 2 | sewer corridor | 1,627 µs | ~36 µs | 36 µs | | 4 | causeway | 1,533 µs | ~39 µs | 39 µs | | 6 | pet 2-tile | 2,364 µs | ~80 µs | 81 µs | | 8 | npc 5-tile | 3,708 µs | ~140 µs | 141 µs | | 9 | npc 8-tile | 2,386 µs | ~200 µs | 197 µs | WarmNoFile and LazyWarm rows should be unchanged — they were always cache-warm. The miss tracker only fires when neither resident chunks nor the lazy reader can satisfy the request. ## Future work (not in this PR) - **Background-thread bake**: builds outside the game thread so even promoted chunks don't pay the 700 µs build cost on the main thread. Rule 10 (no Task.Run) applies, so this needs careful design — the bake is a pure data transform but main-thread synchronization on chunk-state transitions has to be threaded through. Defer to a follow-up. - **Long-traverse BDN scenario**: a multi-Find benchmark simulating 50 pet repaths across chunk transitions. Requires restructuring the bench harness; the existing 10-scenario corpus + Cold provider already exercises the gate. - **Swim sourceZ bake**: scenario 5 (sea serpent) shows 56 B alloc on warm paths because the cache's SourceZ is computed under default-walker rules. Swim creatures fall through to slow path. Independent of this PR.
This commit is contained in:
parent
cff9fbda29
commit
9a3d88988c
14 changed files with 1664 additions and 163 deletions
|
|
@ -58,6 +58,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy)
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
|
|
@ -249,6 +250,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void NonGmPlayer_UsesCache_WithStrictDiagonalRule()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
|
|
@ -277,6 +279,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void DoorCreature_UsesCache_NotSlowPath()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
|
||||
var stub = new DoorOpenerStub(World.NewMobile);
|
||||
|
|
@ -303,6 +306,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void ObstacleCreature_UsesCache_NotSlowPath()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
|
||||
var stub = new ObstacleClimberStub(World.NewMobile);
|
||||
|
|
@ -325,6 +329,74 @@ public class BitmapAStarAlgorithmTests
|
|||
"CanMoveOverObstacles creature should use the cache (movables are dynamic items)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Promotion-gate integration tests. These exercise BitmapAStarAlgorithm.Find()
|
||||
// end-to-end against the live StepCache to prove the per-Find generation gate
|
||||
// actually defers BuildChunk on a single pathfind. They duplicate behavior that
|
||||
// unit tests cover at the cache layer; the value is end-to-end verification that
|
||||
// the bench-relevant scenario (single Find on cleared cache) skips builds entirely.
|
||||
// TODO: REMOVE these two tests once PR-6's gate is proven stable in production BDN.
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Find_SinglePathfindOnClearedCache_DoesNotBuildAnyChunk()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
var stub = new DefaultWalkerStub();
|
||||
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
|
||||
var start = new Point3D(1500, 1600, (sbyte)startZ);
|
||||
var goal = new Point3D(1498, 1598, (sbyte)startZ);
|
||||
stub.MoveToWorld(start, map);
|
||||
|
||||
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||
|
||||
var stats = StepCache.Instance.GetStats();
|
||||
stub.Delete();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0L, stats.BuildsTotal);
|
||||
Assert.True(stats.FallthroughNotBuilt > 0L,
|
||||
$"expected fallthrough on every chunk touched once; got 0 (residents={stats.ResidentChunks})");
|
||||
_output.WriteLine(
|
||||
$"single-Find gate: builds={stats.BuildsTotal} fallthrough_not_built={stats.FallthroughNotBuilt}"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_TwoPathfindsOverlappingChunks_PromoteToBuildOnSecondFind()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
var stub = new DefaultWalkerStub();
|
||||
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
|
||||
var start = new Point3D(1500, 1600, (sbyte)startZ);
|
||||
var goal = new Point3D(1498, 1598, (sbyte)startZ);
|
||||
stub.MoveToWorld(start, map);
|
||||
|
||||
// Find #1: first time anyone touches these chunks. Gate defers; no builds.
|
||||
BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||
var afterFirst = StepCache.Instance.GetStats();
|
||||
Assert.Equal(0L, afterFirst.BuildsTotal);
|
||||
|
||||
// Find #2: same path; chunks now hit their second distinct Find inside the window.
|
||||
// Gate promotes — at least one BuildChunk fires.
|
||||
BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||
var afterSecond = StepCache.Instance.GetStats();
|
||||
|
||||
stub.Delete();
|
||||
|
||||
Assert.True(afterSecond.BuildsTotal > 0L,
|
||||
$"second Find through overlapping chunks must promote (got {afterSecond.BuildsTotal} builds)");
|
||||
_output.WriteLine(
|
||||
$"two-Find gate: first builds={afterFirst.BuildsTotal} second builds={afterSecond.BuildsTotal}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache
|
||||
/// fast path on every expansion.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue