Skip to content

perf: eliminate per-message topic string alloc in comms receive path - #9794

Draft
alejandro-jimenez-dcl wants to merge 5 commits into
mainfrom
bugsweep/comms-topic-string-alloc
Draft

perf: eliminate per-message topic string alloc in comms receive path#9794
alejandro-jimenez-dcl wants to merge 5 commits into
mainfrom
bugsweep/comms-topic-string-alloc

Conversation

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor

Problem

Every incoming LiveKit CommsData message allocates a temporary managed topic string on the
LiveKit callback thread, purely to key a dictionary lookup - including messages for topics
the scene never subscribed to (all scene-bound CommsData traffic reaches every
CommsApiWrap handler). In busy scenes this is a steady off-main-thread GC-pressure
stream. This is the exact TODO tracked by #9206 ("implement GetAlternateLookup ... to avoid
allocation of temp string instances").

Root cause

Topic identity arrives as UTF-8 bytes inside the wire span, but the subscription registry
is only addressable by string, and GetAlternateLookup (span-keyed dictionary lookup) is
a .NET 9 API that Unity 6000.4's BCL does not have - so the hot receive path materialized
the key per message. The registry mutates only via rare JS-driven subscribe/unsubscribe and
scenes use a handful of topics.

Fix (~55 LOC, single file)

CommsApiWrap: a byte-keyed immutable snapshot (TopicLookupEntry[] - UTF-8 topic bytes +
shared queue ref) rebuilt under a lock on successful subscribe/unsubscribe and published
via a volatile field. OnDataReceived volatile-reads the snapshot and linear-scans with
SequenceEqual on the topic span - zero allocation on the miss path; the data-string alloc
on the subscribed branch is required (handed to JS) and unchanged. Copy-on-write
publication means readers never see a partial snapshot; the string-keyed JS-side
ConsumeMessages shares the same queues and is untouched.

Test

In the existing CommsApiWrapShould harness:

  • ReceiveForUnsubscribedTopicDoesNotAllocate - budgeted GC.Alloc profiler-recorder window
    (same idiom as EventBusShould) with a liveness canary: pin allocates ~1 topic string per
    message; fix allocates none.
  • ResubscribeAfterUnsubscribeReceivesAgain - snapshot-rebuild correctness on both
    mutation paths.

Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL as intended (1016 GC.Alloc samples
over 1000 messages vs a <100 budget - exactly one topic-string alloc per message plus the
16-alloc canary; the functional companion passed at pin) / GREEN PASS 2/2. The alloc probe
was hardened during validation (thread-local byte counters are inert on the editor Mono
runtime; the shipped test uses the GC.Alloc recorder + canary idiom).

Fixes #9206

Includes inspection-warning cleanup in all touched files.

Fixes #9206

## Problem

Every incoming LiveKit `CommsData` message allocates a temporary managed topic string on the
LiveKit callback thread, purely to key a dictionary lookup — including messages for topics
the scene never subscribed to (all scene-bound CommsData traffic reaches every
`CommsApiWrap` handler). In busy scenes this is a steady off-main-thread GC-pressure
stream. This is the exact TODO tracked by #9206 ("implement GetAlternateLookup ... to avoid
allocation of temp string instances").

## Root cause

Topic identity arrives as UTF-8 bytes inside the wire span, but the subscription registry
is only addressable by `string`, and `GetAlternateLookup` (span-keyed dictionary lookup) is
a .NET 9 API that Unity 6000.4's BCL does not have — so the hot receive path materialized
the key per message. The registry mutates only via rare JS-driven subscribe/unsubscribe and
scenes use a handful of topics.

## Fix (~55 LOC, single file)

`CommsApiWrap`: a byte-keyed immutable snapshot (`TopicLookupEntry[]` — UTF-8 topic bytes +
shared queue ref) rebuilt under a lock on successful subscribe/unsubscribe and published
via a volatile field. `OnDataReceived` volatile-reads the snapshot and linear-scans with
`SequenceEqual` on the topic span — zero allocation on the miss path; the data-string alloc
on the subscribed branch is required (handed to JS) and unchanged. Copy-on-write
publication means readers never see a partial snapshot; the string-keyed JS-side
`ConsumeMessages` shares the same queues and is untouched.

## Test

In the existing `CommsApiWrapShould` harness:

- `ReceiveForUnsubscribedTopicDoesNotAllocate` — budgeted GC.Alloc profiler-recorder window
  (same idiom as EventBusShould) with a liveness canary: pin allocates ~1 topic string per
  message; fix allocates none.
- `ResubscribeAfterUnsubscribeReceivesAgain` — snapshot-rebuild correctness on both
  mutation paths.

## Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL as intended (1016 GC.Alloc samples
over 1000 messages vs a <100 budget — exactly one topic-string alloc per message plus the
16-alloc canary; the functional companion passed at pin) / GREEN PASS 2/2. The alloc probe
was hardened during validation (thread-local byte counters are inert on the editor Mono
runtime; the shipped test uses the GC.Alloc recorder + canary idiom).

Fixes #9206

Includes inspection-warning cleanup in all touched files.
@alejandro-jimenez-dcl
alejandro-jimenez-dcl requested review from a team as code owners August 19, 2026 12:20
@github-actions
github-actions Bot requested a review from anicalbano August 19, 2026 12:20
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build skipped — no changes detected under Explorer/.

Lint

No C# files changed — lint ratchet skipped.

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25056 0 13
PlayMode ✅ Passed 236 0 37

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 2 — Root-cause check: PASS

The PR fixes the root cause of #9206: per-message string allocation in OnDataReceived caused by Encoding.UTF8.GetString being the only way to key into the string-keyed topicBuffers dictionary, with GetAlternateLookup (span-keyed lookup) being a .NET 9 API unavailable in Unity 6000.4's BCL. The fix introduces a byte-keyed copy-on-write snapshot for zero-allocation topic matching — a direct fix, not a workaround or symptom suppression.

Step 3 — Design & integration: PASS

Is this a new long-lived unit managing a lifecycle? No. TopicLookupEntry[] is an internal derived index within CommsApiWrap, not a standalone manager/system/controller. It does not own or manage any entity, scene, or resource lifecycle — it is a read-optimization of the existing topic subscription registry.

Owner search: CommsApiWrap itself is the owner of topic subscriptions. It is constructed by the scene runtime's JS module system and disposed when the scene unloads. The new topicLookup snapshot shares the same lifecycle — initialized in the field declaration, rebuilt on subscribe/unsubscribe, cleared in Dispose(). No new lifecycle is introduced.

Is topicBuffers still needed alongside topicLookup? Yes — topicBuffers remains the string-keyed source of truth for ConsumeMessages (JS-facing, takes string topic) and for the TryAdd/TryRemove existence checks in subscribe/unsubscribe. topicLookup serves a genuinely different consumer (wire-format byte matching on the LiveKit thread). This is not a redundant-state anti-pattern.

Copy-on-write array pattern: Appropriate for the read/write ratio — extremely hot reads on the LiveKit callback thread, rare writes from JS subscribe/unsubscribe. Writers serialize under topicLookupLock; readers take a zero-cost volatile snapshot. The shared DCLConcurrentQueue references ensure messages enqueued via the byte-keyed path are visible to string-keyed ConsumeMessages.

Teardown trace:

  • topicLookup → set to Array.Empty<>() in Dispose() (line 73) ✓
  • topicLookupLock → plain object, no disposal needed ✓
  • TopicLookupEntry.Utf8Topic (byte[]) → GC-managed, no explicit disposal needed ✓
  • TopicLookupEntry.Queue → shared reference to queue in topicBuffers, cleared via topicBuffers.Clear()

Step 4 — Member audit

New public members: None. TopicLookupEntry and RebuildTopicLookup() are both private.

Changed public member: PublishData(string topic, string? data) — the data parameter changed from string to string?. This is a nullable annotation correction, not a behavioral change: the method body already handled null via if (string.IsNullOrEmpty(data)) return; (unchanged), and the existing RejectNullData test (also unchanged) asserted this behavior. The annotation now truthfully reflects what the ClearScript/JS boundary can pass.

Step 5 — Line-level findings

One P2 finding (inline comment below).

Security: No vulnerabilities. Input validation in OnDataReceived (span length checks before slicing) is unchanged and correct. The volatile + lock COW pattern is correctly implemented. TopicLookupEntry.Utf8Topic (byte[]) is created inside RebuildTopicLookup and never exposed outside the private nested type.

Thread safety: Correct. Writers serialize under topicLookupLock; the LiveKit-thread reader takes a volatile snapshot. The self-healing property of full-rebuild-from-dictionary means concurrent subscribe/unsubscribe calls always converge to the correct state.

Performance: The allocation elimination is effective and complete for the unsubscribed-topic path. SequenceEqual on ReadOnlySpan<byte> vs byte[] (implicit conversion to ReadOnlySpan<byte>) is allocation-free. The linear scan is appropriate for the expected cardinality (a handful of topics per scene).

Tests: Both new tests follow the AAA pattern, use NUnit conventions, and cover the key correctness properties: zero-alloc verification with a liveness canary (sound GC.Alloc Recorder methodology), and snapshot-rebuild correctness across unsubscribe + resubscribe.

Step 6 — Complexity: COMPLEX

Touches the LiveKit callback thread's message-processing path and involves concurrent data structure design (volatile + lock COW pattern). Memory management optimization targeting GC pressure.

Step 7 — QA: YES

Modifies runtime comms message processing code that ships in the Unity player build.

Step 8 — Non-blocking warnings

⚠️ Branch target: This PR targets main but the repository's default branch is dev. Verify this is intentional per the current branch model.

Step 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches the LiveKit-thread comms receive path with a copy-on-write concurrent data structure for GC-pressure elimination
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: perf: eliminate per-message topic string alloc in comms receive path


STEP 2 — Root-cause check: ✅ PASS

The PR correctly identifies and fixes the root cause: topic identity arrives as UTF-8 bytes on the wire, but the subscription registry was only addressable by string, and GetAlternateLookup (span-keyed dictionary lookup) is a .NET 9 API unavailable in Unity 6000.4's BCL — forcing a per-message string materialization purely for a dictionary key lookup. The fix uses a copy-on-write byte-keyed snapshot for zero-allocation matching on the hot path. This is the right approach given the platform constraint.

STEP 3 — Design & integration: ✅ PASS

New unit assessment: No new system, manager, or service is introduced. The change adds a private field (topicLookup), a private method (RebuildTopicLookup), and a private readonly struct (TopicLookupEntry) — all scoped within the existing CommsApiWrap class. The lifecycle is managed by the same class that owns topicBuffers.

Owner search: CommsApiWrap is constructed in ISceneRuntime.cs (line 175, via SceneFactory), registered as the "CommsApi" JS module, and disposed on scene unload. The snapshot lifecycle is tied to subscribe/unsubscribe calls (JS-driven, rare) and the class's Dispose(). No external lifecycle owner is needed — the existing class is the correct home for this optimization.

Dual data structure consistency: topicBuffers (string-keyed, used by ConsumeMessages on JS thread) and topicLookup (byte-keyed, used by OnDataReceived on LiveKit thread) share the same DCLConcurrentQueue references. Writers rebuild topicLookup under topicLookupLock immediately after mutating topicBuffers. The brief desync window (sub-microsecond) is inherent to copy-on-write and self-correcting. At worst, a message on a just-subscribed topic is missed during the rebuild window, or a message on a just-unsubscribed topic is enqueued to an unreachable queue. Both are benign and transient.

Teardown trace:

Acquired Teardown Location
onDataReceivedCached handler registration RemoveSceneMessageHandler Dispose() line 71
topicBuffers entries topicBuffers.Clear() Dispose() line 72
topicLookup snapshot topicLookup = Array.Empty<>() Dispose() line 73
commsWriter commsWriter.Dispose() Dispose() line 75

All acquired resources have matching teardown. No leaks.

Dispose ordering note (not a finding): Dispose() resets topicLookup without acquiring topicLookupLock. A theoretical race exists if a concurrent SubscribeToTopic triggers RebuildTopicLookup between topicBuffers.Clear() (line 72) and the volatile write (line 73). This is harmless in practice: the handler is removed first (line 71) so no new OnDataReceived calls arrive, and any in-flight call is working with an already-captured snapshot. Post-Dispose subscribe calls would be a lifecycle violation by the caller.

STEP 4 — Member audit: ✅ PASS

New member Consumers Assessment
topicLookupLock (private) RebuildTopicLookup Standard lock object for serializing snapshot rebuilds
topicLookup (private volatile) OnDataReceived (read), RebuildTopicLookup (write), Dispose (write) Correct volatile publish/read pattern
RebuildTopicLookup() (private) SubscribeToTopic, UnsubscribeFromTopic Two callers — appropriate extraction
TopicLookupEntry (private readonly struct) topicLookup, RebuildTopicLookup, OnDataReceived Clean value type, correct readonly struct usage

No single-use merge candidates. No absent≠false issues. No redundant guards.

STEP 5 — Line-level review: ✅ No issues found

Threading model: The copy-on-write pattern (volatile publish for readers, lock for writers) is textbook for this access profile — rare writers (JS subscribe/unsubscribe), frequent readers (LiveKit callback thread). ConcurrentDictionary enumeration in RebuildTopicLookup is snapshot-safe on non-WebGL. On WebGL (single-threaded), no concurrency concern.

Hot path allocation audit: OnDataReceived is allocation-free on the miss path (unsubscribed topics). On the hit path, only the intentional Encoding.UTF8.GetString (line 299, data string handed to JS) and the BufferedDataMessage struct enqueue (no boxing — generic concurrent queue) allocate. No LINQ, no closures, no hidden allocations.

Input validation: Wire format bounds are correctly validated (lines 274, 278) before any span slicing. topicLength as ushort combined with the length check prevents out-of-bounds access.

Signature change: PublishData(string topic, string? data) correctly aligns the nullable annotation with actual behavior. The string.IsNullOrEmpty(data) guard (line 146) handles both null and empty, with test coverage (RejectNullData, RejectEmptyData).

Linear scan correctness: The for loop with SequenceEqual + return on match (lines 286–302) is functionally equivalent to the previous TryGetValue — topics are unique (sourced from a dictionary) so at most one match exists. The return after the match correctly short-circuits the scan.

Security: No new attack surface introduced. Wire format validation is unchanged and sound. The byte[] in TopicLookupEntry is never exposed outside the private readonly struct and only read via SequenceEqual — no mutation path exists.

Tests: Both new tests follow AAA pattern, use NUnit correctly, and cover the optimization's two key properties:

  • ReceiveForUnsubscribedTopicDoesNotAllocate: GC.Alloc Recorder with liveness canary — validates zero allocation on the miss path
  • ResubscribeAfterUnsubscribeReceivesAgain: Snapshot rebuild correctness on both mutation paths (subscribe and unsubscribe)

null! usage on test fields (lines 21–25) is the standard pattern for [SetUp]-initialized fields — acceptable per convention.

STEP 6 — Complexity: COMPLEX

Touches the LiveKit comms receive path threading model and memory allocation patterns on the callback thread.

STEP 7 — QA: YES

Modifies runtime code under Explorer/ that ships in the build. The change affects how incoming CommsData messages are matched to subscribed topics on the LiveKit callback thread.

STEP 8 — Warnings: None

Main scene not modified.

STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies the LiveKit comms receive path threading model and allocation patterns on the callback thread
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

@alejandro-jimenez-dcl
alejandro-jimenez-dcl marked this pull request as draft August 19, 2026 12:48
@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9794, run #32262975226

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2313 (×3) 2086 (×3)
CPU average 38.6 ms (33.4–38.8) 42.7 ms (37.2–46.7) 4.1 ms ⚪ within noise
CPU 1% worst 322.6 ms (57.0–343.5) 537.1 ms (440.8–649.3) 214.6 ms ⚪ within noise
CPU 0.1% worst 344.1 ms (341.1–360.3) 565.3 ms (555.3–671.7) 221.1 ms 🔴 64% slower
GPU average 9.5 ms (9.2–9.6) 9.7 ms (9.5–10.0) 0.1 ms ⚪ within noise
GPU 1% worst 35.6 ms (23.5–37.7) 56.7 ms (48.3–64.2) 21.1 ms 🔴 59% slower
GPU 0.1% worst 44.4 ms (39.8–45.0) 64.7 ms (64.5–74.2) 20.4 ms 🔴 46% slower
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4105 (×3) 4088 (×3)
CPU average 21.8 ms (21.8–22.9) 22.0 ms (20.8–23.1) 0.1 ms ⚪ within noise
CPU 1% worst 215.9 ms (215.7–217.7) 230.2 ms (133.4–233.5) 14.3 ms ⚪ within noise
CPU 0.1% worst 226.3 ms (222.9–228.8) 235.2 ms (233.7–235.5) 8.9 ms 🔴 4% slower
GPU average 2.5 ms (2.0–3.2) 2.5 ms (2.3–9.0) -0.0 ms ⚪ within noise
GPU 1% worst 34.3 ms (34.2–36.2) 34.5 ms (33.8–36.3) 0.2 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (34.8–37.5) 35.8 ms (34.6–37.6) -0.5 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

@alejandro-jimenez-dcl alejandro-jimenez-dcl left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approved

lorenzo-ranciaffi added a commit that referenced this pull request Aug 21, 2026
…9794)

Squashed changes from PR #9794.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants