Perf: Avoid a copy of ComponentChanges every tick within Checkpoints (#5146)

* Perf: Avoid a copy of ComponentChanges every tick within Checkpoints

- Also remove temporary Dictionary created every tick * every change
- Reduces GC load, 6GB less temporary allocations on typical replays.

* perf: Checkpoints: Apply state changes in-place when possible

- Avoids >1GB of gas tile allocations.

* Revert "perf: Checkpoints: Apply state changes in-place when possible"

This reverts commit 1a478944a6.

* Fix delta state merge issues

---------

Co-authored-by: ElectroJr <leonsfriedrich@gmail.com>
This commit is contained in:
Tom Leys
2024-06-05 20:06:38 +12:00
committed by GitHub
parent 87d8d74d8c
commit bde650689b

View File

@@ -20,6 +20,31 @@ namespace Robust.Client.Replays.Loading;
// so that when jumping to tick 1001 the client only has to apply states for tick 1000 and 1001, instead of 0, 1, 2, ...
public sealed partial class ReplayLoadManager
{
// Scratch data used by UpdateEntityStates.
// Avoids copying changes for every change to an entity between checkpoints, instead copies once per checkpoint on
// first change. We can also use this to avoid building a dictionary of ComponentChange inside the inner loop.
private class UpdateScratchData
{
public Dictionary<ushort, ComponentChange> Changes;
public EntityState lastChange;
public HashSet<ushort>? netComps;
public UpdateScratchData(EntityState oldEntState)
{
Changes = oldEntState.ComponentChanges.Value.ToDictionary(x => x.NetID);
lastChange = oldEntState;
netComps = oldEntState.NetComponents;
}
public EntityState BakeChanges()
{
return new EntityState(lastChange.NetEntity,
Changes.Values.ToList(),
lastChange.EntityLastModified,
netComps);
}
}
public async Task<(CheckpointState[], TimeSpan[])> GenerateCheckpointsAsync(
ReplayMessage? initMessages,
HashSet<string> initialCvars,
@@ -138,6 +163,7 @@ public sealed partial class ReplayLoadManager
var stats_due_spawned = 0;
var stats_due_state = 0;
var modifiedEntities = new Dictionary<NetEntity, UpdateScratchData>();
for (var i = 1; i < states.Count; i++)
{
if (i % 10 == 0)
@@ -148,10 +174,10 @@ public sealed partial class ReplayLoadManager
DebugTools.Assert(curState.FromSequence <= lastState.ToSequence);
UpdatePlayerStates(curState.PlayerStates.Span, playerStates);
UpdateEntityStates(curState.EntityStates.Span, entStates, ref spawnedTracker, ref stateTracker, detached);
UpdateEntityStates(curState.EntityStates.Span, entStates, modifiedEntities, ref spawnedTracker, ref stateTracker, detached);
UpdateMessages(messages[i], uploadedFiles, prototypes, cvars, detachQueue, ref timeBase);
ProcessQueue(curState.ToSequence, detachQueue, detached, entStates);
UpdateDeletions(curState.EntityDeletions, entStates, detached);
UpdateDeletions(curState.EntityDeletions, entStates, detached, modifiedEntities);
serverTime[i] = GetTime(curState.ToSequence) - initialTime;
ticksSinceLastCheckpoint++;
@@ -182,6 +208,8 @@ public sealed partial class ReplayLoadManager
ticksSinceLastCheckpoint = 0;
spawnedTracker = 0;
stateTracker = 0;
ApplyModifiedEntities(entStates, modifiedEntities);
var newState = new GameState(GameTick.Zero,
curState.ToSequence,
default,
@@ -339,16 +367,18 @@ public sealed partial class ReplayLoadManager
}
private void UpdateDeletions(NetListAsArray<NetEntity> entityDeletions,
Dictionary<NetEntity, EntityState> entStates, HashSet<NetEntity> detached)
Dictionary<NetEntity, EntityState> entStates, HashSet<NetEntity> detached, Dictionary<NetEntity, UpdateScratchData> modifiedEntities)
{
foreach (var ent in entityDeletions.Span)
{
entStates.Remove(ent);
detached.Remove(ent);
modifiedEntities.Remove(ent);
}
}
private void UpdateEntityStates(ReadOnlySpan<EntityState> span, Dictionary<NetEntity, EntityState> entStates,
Dictionary<NetEntity, UpdateScratchData> modified,
ref int spawnedTracker, ref int stateTracker, HashSet<NetEntity> detached)
{
foreach (var entState in span)
@@ -369,9 +399,22 @@ public sealed partial class ReplayLoadManager
continue;
}
// Get scratch versions (with write access) for entities modified since last checkpoint
UpdateScratchData? scratch;
if (!modified.TryGetValue(entState.NetEntity, out scratch))
{
scratch = new UpdateScratchData(oldEntState);
modified[entState.NetEntity] = scratch;
}
stateTracker++;
DebugTools.Assert(oldEntState.NetEntity == entState.NetEntity);
entStates[entState.NetEntity] = MergeStates(entState, oldEntState.ComponentChanges.Value, oldEntState.NetComponents);
// Note this does not change entStates, that change occurs later in ApplyModifiedEntities (to avoid early copies)
UpdateScratch(entState, scratch.Changes);
if (entState.NetComponents != null)
scratch.netComps = entState.NetComponents;
scratch.lastChange = entState;
#if DEBUG
foreach (var state in entStates[entState.NetEntity].ComponentChanges.Span)
@@ -382,6 +425,53 @@ public sealed partial class ReplayLoadManager
}
}
private void ApplyModifiedEntities(Dictionary<NetEntity, EntityState> entStates, Dictionary<NetEntity, UpdateScratchData> modifiedEntities)
{
foreach (var modified in modifiedEntities)
{
entStates[modified.Key] = modified.Value.BakeChanges();
}
modifiedEntities.Clear();
}
private void UpdateScratch(
EntityState newState,
Dictionary<ushort, ComponentChange> oldState)
{
// remove any deleted components
if (newState.NetComponents != null)
{
foreach (var change in oldState.Values)
{
if (!newState.NetComponents.Contains(change.NetID))
oldState.Remove(change.NetID);
}
}
foreach (var newCompState in newState.ComponentChanges.Value)
{
if (!oldState.TryGetValue(newCompState.NetID, out var existing))
{
// This is a new component
// I'm not 100% sure about this, but I think delta states should always be full states here?
DebugTools.Assert(newCompState.State is not IComponentDeltaState newDelta);
oldState[newCompState.NetID] = newCompState;
continue;
}
// Modify or replace existing component
if (newCompState.State is not IComponentDeltaState delta)
{
oldState[newCompState.NetID] = newCompState;
continue;
}
DebugTools.Assert(existing.State != null && existing.State is not IComponentDeltaState);
oldState[newCompState.NetID] = new ComponentChange(existing.NetID, delta.CreateNewFullState(existing.State!), newCompState.LastModifiedTick);
}
}
private EntityState MergeStates(
EntityState newState,
IReadOnlyCollection<ComponentChange> oldState,
@@ -420,7 +510,7 @@ public sealed partial class ReplayLoadManager
foreach (var compChange in newCompStates.Values)
{
// I'm not 100% sure about this, but I think delta states should always be full states here?
DebugTools.Assert(compChange.State is not IComponentDeltaState delta);
DebugTools.Assert(compChange.State is not IComponentDeltaState);
combined.Add(compChange);
}