Skip to content

Commit 844d3ee

Browse files
committed
opti(comms-profiles): skip the remove-intentions lock on empty frames
RemoteEntitiesExtensions.Remove ran every frame and unconditionally built an OwnedBunch<RemoveIntention>, whose ctor acquires MutexSync even when there is nothing to remove. Add a racy, lock-free NewBunchAvailable() pre-check (mirrors RemoteProfiles.NewBunchAvailable()) so the lock is skipped entirely on empty frames.
1 parent 4d641c5 commit 844d3ee

8 files changed

Lines changed: 276 additions & 0 deletions

File tree

Explorer/Assets/DCL/Multiplayer/Movement/Systems/MultiplayerContainer.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ public RemoveIntentionsProxy(PulseRemoveIntentions pulseRemoveIntentions, LiveKi
169169
this.liveKitRemoveIntentions = liveKitRemoveIntentions;
170170
}
171171

172+
public bool NewBunchAvailable() =>
173+
pulseRemoveIntentions.NewBunchAvailable() || liveKitRemoveIntentions.NewBunchAvailable();
174+
172175
public OwnedBunch<RemoveIntention> Bunch()
173176
{
174177
using OwnedBunch<RemoveIntention> pulse = pulseRemoveIntentions.Bunch();

Explorer/Assets/DCL/Multiplayer/Profiles/Entities/IRemoteEntities.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public static void TryCreate(this IRemoteEntities remoteEntities, RemoteProfiles
3232

3333
public static void Remove(IRemoteEntities remoteEntities, IRemoteAnnouncements announcements, IRemoveIntentions removeIntentions, World world)
3434
{
35+
if (removeIntentions.NewBunchAvailable() == false)
36+
return;
37+
3538
using OwnedBunch<RemoveIntention> bunch = removeIntentions.Bunch();
3639
IReadOnlyCollection<RemoveIntention> collection = bunch.Collection();
3740
remoteEntities.Remove(collection, world);

Explorer/Assets/DCL/Multiplayer/Profiles/RemoveIntentions/IRemoveIntentions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ namespace DCL.Multiplayer.Profiles.RemoveIntentions
44
{
55
public interface IRemoveIntentions
66
{
7+
/// <summary>
8+
/// Racy, lock-free peek of the pending-intention count. Lets the per-frame drain
9+
/// skip constructing an <see cref="OwnedBunch{T}"/> (which acquires+releases the
10+
/// backing <c>MutexSync</c>) when there is nothing to remove. Mirrors
11+
/// <c>RemoteProfiles.NewBunchAvailable()</c>.
12+
/// <para>
13+
/// May observe a just-published intention one frame late; the item stays queued and
14+
/// is picked up on the next frame (bounded one-frame staleness — never dropped).
15+
/// </para>
16+
/// </summary>
17+
bool NewBunchAvailable();
18+
719
OwnedBunch<RemoveIntention> Bunch();
820
}
921
}

Explorer/Assets/DCL/Multiplayer/Profiles/RemoveIntentions/LiveKitRemoveIntentions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ private void ParticipantsOnUpdatesFromParticipant(LKParticipant participant, Upd
7878
roomHub.SceneRoom().Room().ConnectionUpdated -= OnConnectionUpdateFromScene;
7979
}
8080

81+
public bool NewBunchAvailable() =>
82+
list.Count > 0;
83+
8184
public OwnedBunch<RemoveIntention> Bunch() =>
8285
new(multithreadSync, list);
8386

Explorer/Assets/DCL/Multiplayer/Profiles/RemoveIntentions/LogRemoveIntentions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ public LogRemoveIntentions(IRemoveIntentions origin)
1313
this.origin = origin;
1414
}
1515

16+
public bool NewBunchAvailable() =>
17+
origin.NewBunchAvailable();
18+
1619
public OwnedBunch<RemoveIntention> Bunch()
1720
{
1821
OwnedBunch<RemoveIntention> bunch = origin.Bunch();

Explorer/Assets/DCL/Multiplayer/Profiles/RemoveIntentions/PulseRemoveIntentions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public void Cancel(string walletId)
2323
set.Remove(new RemoveIntention(walletId, RoomSource.Pulse));
2424
}
2525

26+
public bool NewBunchAvailable() =>
27+
set.Count > 0;
28+
2629
public OwnedBunch<RemoveIntention> Bunch() =>
2730
new (mutexSync, set);
2831
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using Arch.Core;
2+
using DCL.Multiplayer.Profiles.Announcements;
3+
using DCL.Multiplayer.Profiles.Entities;
4+
using DCL.Multiplayer.Profiles.RemoteProfiles;
5+
using DCL.Multiplayer.Profiles.RemoveIntentions;
6+
using NUnit.Framework;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Threading;
10+
using Unity.PerformanceTesting;
11+
12+
namespace DCL.Tests.PlayMode.PerformanceTests
13+
{
14+
/// <summary>
15+
/// <c>MultiplayerProfilesSystem.Update</c> calls <see cref="RemoteEntitiesExtensions.Remove"/> every
16+
/// frame once loading completes. <c>IRemoveIntentions.NewBunchAvailable()</c> is a racy, lock-free
17+
/// pre-check (mirrors <c>RemoteProfiles.NewBunchAvailable()</c>) that lets an empty-set frame — the
18+
/// overwhelmingly common case at ~60-120 Hz — skip constructing an <c>OwnedBunch&lt;RemoveIntention&gt;</c>,
19+
/// whose ctor acquires+releases the backing <c>MutexSync</c> (a kernel <see cref="System.Threading.Mutex"/>).
20+
///
21+
/// <para>The tests below cover: (a) the empty-set fast path actually elides the lock round-trip,
22+
/// (b) the racy pre-check never drops an intention (a stale read is just picked up next frame), and
23+
/// (c) the lock still serializes concurrent writers against the drain without torn state.</para>
24+
/// </summary>
25+
[Category("Performance")]
26+
public class RemoveIntentionsFastPathPerformanceTest
27+
{
28+
29+
private sealed class RecordingRemoteEntities : IRemoteEntities
30+
{
31+
public readonly List<string> Consumed = new (16_384);
32+
public int RemoveCalls;
33+
34+
public void Initialize(RemoteAvatarCollider remoteAvatarCollider) { }
35+
36+
public void TryCreateOrUpdate(IReadOnlyCollection<RemoteProfile> list, World world) { }
37+
38+
public void Remove(IReadOnlyCollection<RemoveIntention> list, World world)
39+
{
40+
RemoveCalls++;
41+
42+
foreach (RemoveIntention intention in list)
43+
Consumed.Add(intention.WalletId);
44+
}
45+
46+
public void ForceRemoveAll(World world) { }
47+
}
48+
49+
private sealed class NoOpAnnouncements : IRemoteAnnouncements
50+
{
51+
public void Fill(List<RemoteAnnouncement> announcements) { }
52+
53+
public void Remove(IReadOnlyCollection<RemoveIntention> removeIntentions) { }
54+
}
55+
56+
private static double MedianOf(string sampleGroupName)
57+
{
58+
List<double> samples = PerformanceTest.Active.SampleGroups
59+
.Single(g => g.Name == sampleGroupName)
60+
.Samples
61+
.OrderBy(x => x)
62+
.ToList();
63+
64+
int n = samples.Count;
65+
return n % 2 == 1 ? samples[n / 2] : (samples[(n / 2) - 1] + samples[n / 2]) * 0.5d;
66+
}
67+
68+
/// <summary>
69+
/// (a) Steady-state cost. Measures the real per-frame entry point with an EMPTY intention set
70+
/// (the overwhelmingly common case) against a forced lock round-trip via <c>Bunch()</c>. CI-safe:
71+
/// the assertion compares the two sample-group medians directly rather than a wall-clock absolute.
72+
/// If the <c>NewBunchAvailable()</c> pre-check stops eliding the lock, the two medians converge.
73+
/// </summary>
74+
[Test, Performance]
75+
public void EmptySet_Remove_ElidesMutexRoundTrip()
76+
{
77+
World world = World.Create();
78+
var intentions = new PulseRemoveIntentions();
79+
var entities = new RecordingRemoteEntities();
80+
var announcements = new NoOpAnnouncements();
81+
82+
Measure.Method(() => RemoteEntitiesExtensions.Remove(entities, announcements, intentions, world))
83+
.SampleGroup("remove_empty_fastpath")
84+
.WarmupCount(5)
85+
.MeasurementCount(50)
86+
.IterationsPerMeasurement(2000)
87+
.GC()
88+
.Run();
89+
90+
Measure.Method(() =>
91+
{
92+
using (intentions.Bunch()) { }
93+
})
94+
.SampleGroup("bunch_mutex_roundtrip")
95+
.WarmupCount(5)
96+
.MeasurementCount(50)
97+
.IterationsPerMeasurement(2000)
98+
.GC()
99+
.Run();
100+
101+
world.Dispose();
102+
103+
double fast = MedianOf("remove_empty_fastpath");
104+
double slow = MedianOf("bunch_mutex_roundtrip");
105+
double ratio = slow / fast;
106+
107+
TestContext.WriteLine($"empty-set fast path median = {fast:F6} ms / 2000 iters");
108+
TestContext.WriteLine($"mutex round-trip median = {slow:F6} ms / 2000 iters");
109+
TestContext.WriteLine($"speedup ratio = {ratio:F2}x (higher = pre-check is eliding more work)");
110+
111+
Assert.That(fast, Is.LessThan(slow),
112+
"Empty-set Remove must be cheaper than a forced mutex round-trip; the racy pre-check is not eliding the lock.");
113+
114+
Assert.That(entities.RemoveCalls, Is.Zero, "Empty-set fast path must not invoke IRemoteEntities.Remove at all.");
115+
}
116+
117+
/// <summary>
118+
/// (b) Correctness under the racy fast path (lost-wakeup guard). A producer enqueues M distinct
119+
/// intentions in bursts with random gaps while a consumer loop calls the production
120+
/// <see cref="RemoteEntitiesExtensions.Remove"/> each simulated frame. Asserts every intention is
121+
/// consumed exactly once and none is lost: the volatile/racy pre-check may observe a publish stale
122+
/// for one frame, but never drops it — it is simply picked up the next frame.
123+
/// </summary>
124+
[Test]
125+
public void RacyFastPath_ConsumesEveryIntentionExactlyOnce()
126+
{
127+
const int M = 1000;
128+
129+
World world = World.Create();
130+
var intentions = new PulseRemoveIntentions();
131+
var entities = new RecordingRemoteEntities();
132+
var announcements = new NoOpAnnouncements();
133+
134+
var produced = new string[M];
135+
for (var i = 0; i < M; i++) produced[i] = $"0xwallet{i:D5}";
136+
137+
var producerDone = false;
138+
139+
var producer = new Thread(() =>
140+
{
141+
var rnd = new System.Random(0xC0FFEE);
142+
143+
foreach (string wallet in produced)
144+
{
145+
intentions.Enqueue(wallet);
146+
147+
if (rnd.Next(5) == 0)
148+
Thread.Sleep(0);
149+
}
150+
151+
Volatile.Write(ref producerDone, true);
152+
}) { IsBackground = true };
153+
154+
producer.Start();
155+
156+
var guard = 0;
157+
158+
while (!Volatile.Read(ref producerDone) || intentions.NewBunchAvailable())
159+
{
160+
RemoteEntitiesExtensions.Remove(entities, announcements, intentions, world);
161+
162+
if (++guard > 20_000_000)
163+
Assert.Fail("Consumer failed to converge — possible lost wakeup / stuck pre-check.");
164+
165+
Thread.Yield();
166+
}
167+
168+
RemoteEntitiesExtensions.Remove(entities, announcements, intentions, world);
169+
170+
producer.Join();
171+
world.Dispose();
172+
173+
Assert.That(entities.Consumed.Count, Is.EqualTo(M),
174+
"Every enqueued intention must be consumed exactly once (none lost by the racy pre-check).");
175+
Assert.That(entities.Consumed.Distinct().Count(), Is.EqualTo(M),
176+
"No intention may be consumed twice or dropped — exactly-once delivery.");
177+
}
178+
179+
/// <summary>
180+
/// (c) Concurrent-writer stress. Two writer threads simultaneously enqueue disjoint ranges while the
181+
/// consumer drains, exercising the <c>MutexSync</c> lock as the serialization point
182+
/// between concurrent <c>Enqueue</c> (HashSet.Add) and the drain. A broken lock would surface as a
183+
/// torn HashSet (lost/duplicated adds) or an exception. Asserts the final entity state is the full
184+
/// 2N union, each consumed exactly once.
185+
/// </summary>
186+
[Test]
187+
public void ConcurrentWriters_NoTornStateAllConsumedOnce()
188+
{
189+
const int N = 5000;
190+
191+
World world = World.Create();
192+
var intentions = new PulseRemoveIntentions();
193+
var entities = new RecordingRemoteEntities();
194+
var announcements = new NoOpAnnouncements();
195+
196+
var doneA = false;
197+
var doneB = false;
198+
199+
var writerA = new Thread(() =>
200+
{
201+
for (var i = 0; i < N; i++) intentions.Enqueue($"0xA{i:D6}");
202+
Volatile.Write(ref doneA, true);
203+
}) { IsBackground = true };
204+
205+
var writerB = new Thread(() =>
206+
{
207+
for (var i = 0; i < N; i++) intentions.Enqueue($"0xB{i:D6}");
208+
Volatile.Write(ref doneB, true);
209+
}) { IsBackground = true };
210+
211+
writerA.Start();
212+
writerB.Start();
213+
214+
var guard = 0;
215+
216+
while (!Volatile.Read(ref doneA) || !Volatile.Read(ref doneB) || intentions.NewBunchAvailable())
217+
{
218+
RemoteEntitiesExtensions.Remove(entities, announcements, intentions, world);
219+
220+
if (++guard > 40_000_000)
221+
Assert.Fail("Consumer failed to converge under concurrent writers.");
222+
223+
Thread.Yield();
224+
}
225+
226+
RemoteEntitiesExtensions.Remove(entities, announcements, intentions, world);
227+
228+
writerA.Join();
229+
writerB.Join();
230+
world.Dispose();
231+
232+
Assert.That(entities.Consumed.Count, Is.EqualTo(2 * N),
233+
"the MutexSync lock must serialize concurrent Enqueue against the drain — no lost or torn adds.");
234+
Assert.That(entities.Consumed.Distinct().Count(), Is.EqualTo(2 * N),
235+
"Final entity state must be the full 2N union, each consumed exactly once — no lost or duplicated adds.");
236+
}
237+
}
238+
}

Explorer/Assets/DCL/Tests/PlayMode/PerformanceTests/RemoveIntentionsFastPathPerformanceTest.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)