UE 5.8 `TArray::AddUnique` in Chaos solver teardown

Summary

After stopping Play-In-Editor in a World Partition open-world project, the editor game
thread blocks for anywhere between 7 minutes and over an hour. During the stall exactly
one thread spins at 100% of a single core, with zero disk I/O and zero memory
allocation
.

CPU sampling identifies a single hot function responsible for essentially all of the
stall time:

UnrealEditor-Chaos.dll!TArray<Chaos::FPBDRigidsSolver::FPendingDestroyInfo,
                              TSizedDefaultAllocator<32> >::AddUniqueImpl<...>

TArray::AddUnique performs a linear scan of the array before every insertion. Building
the pending-destroy list therefore costs O(N²) in the number of physics proxies being
unregistered. On a streamed open world with tens of thousands of physics bodies this
degenerates into a multi-minute to multi-hour single-threaded stall.

This is a regression: the same project on UE 5.4 exited PIE in a few seconds.

What Type of Bug are you experiencing?

Editor

Steps to Reproduce

  1. Open a large World Partition project containing many collision-enabled / simulating primitive components.
  2. Start PIE (Selected Viewport or New Editor Window — both reproduce).
  3. Move through the world so streaming loads a substantial amount of the map.
  4. Stop PIE.

Expected Result

Expected: editor returns to responsive state within a few seconds.

Observed Result

Actual: editor window stops responding. One thread spins at 100% of one core.
Recovery takes 7 minutes to over an hour. The stall duration scales super-linearly with how much of the world was streamed in during the session — consistent with O(N²).

Note: Play → Standalone Game does not reproduce, because teardown happens in the
child process rather than in the editor’s Chaos solver.

Affects Versions

5.8

Platform(s)

Windows

Additional Notes

Evidence

1. CPU sampling (xperf, -a profile -detail, symbols resolved)

Top samples inside UnrealEditor.exe during the stall:

Weight        Module!Function
------------  -----------------------------------------------------------------
24,501,561    UnrealEditor-Chaos.dll!TArray<Chaos::FPBDRigidsSolver::FPendingDestroyInfo,
                  TSizedDefaultAllocator<32> >::AddUniqueImpl<...>
   203,260    nvlddmkm.sys!"Unknown"
   139,361    ntoskrnl.exe!RtlpLookupFunctionEntryForStackWalks
    89,201    UnrealEditor-Core.dll!LowLevelTasks::Private::FWaitingQueue::Park
    ...
    17,028    UnrealEditor-Chaos.dll!`Chaos::FPBDRigidsSolver::UnregisterObject'::`2'::<lambda_1>::operator()
    17,007    UnrealEditor-Chaos.dll!Chaos::FEventManager::ClearEvents<Chaos::FCollisionEventData>

The hot function outweighs the next entry by ~120x. FPBDRigidsSolver::UnregisterObject
appears as the caller.

2. The stall is not garbage collection

With LogGarbage=Verbose, the GC itself completes normally and quickly. Reachability
analysis over 5.6M references / 359k objects takes 98 ms; the whole collection is ~370 ms:

LogGarbage: Verbose:   9.718798 ms for MarkObjectsAsUnreachable Phase (78953 Objects To Serialize)
LogGarbage: Verbose:  88.661697 ms for Reachability Analysis
LogGarbage: 98.45 ms for GC - 57018 refs/ms while processing 5613402 references from 359442 objects
LogGarbage: 0.893101 ms for Gather Unreachable Objects (16403 objects collected / 381946 scanned)
LogGarbage: 182.587501 ms for unhashing unreachable objects (16403 objects unhashed)
        <-- log stops here; stall begins; no purge progress lines are emitted

For comparison, a mid-session GC in the same run purges incrementally without issue:

LogGarbage: GC purged 6819 objects (381791 -> 374972) in 8.995ms (5 iteration(s))

The stall therefore sits in the object destruction phase that follows unhashing, where
Chaos unregisters its proxies.

3. Resource counters during the stall (6-second sample)

CPU          : 6.13 s over 6 s   (102% of one core)
page faults  : 306
IO operations: 0
IO bytes     : 0.0 MB
commit delta : 0.0 MB   (stable at 52,505 MB)
working set  : +3.6 MB  (stable at 11.10 GB)

Pure computation. Rules out paging, disk I/O, allocation pressure and lock contention.

4. Observed stall durations

PIE stop Recovery Stall
13:58:52 14:05:53 ~7 min
14:38:10 14:58:04 ~20 min
17:13:07 17:39:30 ~26 min
later sessions up to ~1 hour

Unreachable object counts at PIE exit in these runs: 16,403 and 21,433 objects, out of
~380k total UObjects.

Analysis

FPBDRigidsSolver::UnregisterObject appends to a TArray<FPendingDestroyInfo> using
AddUnique. AddUnique is O(N) per call, so populating the list for N unregistering
proxies is O(N²). The constant factor is small enough that small levels appear fine,
which is likely why this passed testing, but on a streamed open world N reaches tens of
thousands and the quadratic term dominates completely.

Suggested fix

Replace the linear uniqueness check with a hashed container — e.g. keep a
TSet<FPhysicsProxyHandle> (or TMap) alongside the array for membership testing, or
drop the uniqueness requirement entirely if callers already guarantee it.