Skip to content

netcode performance: Add optional FP16 quantization; reduce replication overhead - #1343

Open
mcdubhghlas wants to merge 4 commits into
Redot-Engine:masterfrom
mcdubhghlas:net-improve
Open

netcode performance: Add optional FP16 quantization; reduce replication overhead#1343
mcdubhghlas wants to merge 4 commits into
Redot-Engine:masterfrom
mcdubhghlas:net-improve

Conversation

@mcdubhghlas

@mcdubhghlas mcdubhghlas commented Aug 7, 2026

Copy link
Copy Markdown
Member

benchmarks

$ ./bin/redot.linuxbsd.editor.x86_64 --headless --test --no-skip "--test-case=*[SceneReplication][Benchmark]*"
[doctest] doctest version is "2.4.12"
[doctest] run with "--help" for options

=== Replication precision benchmark ===
Snapshot fields: position(Vector3), velocity(Vector3), facing(float), health(float), mana(float)
SIZE   full = 56 bytes   half = 23 bytes   reduction = 58.9%
ACCURACY (single Vector3 at increasing magnitude, worst-component abs error):
  |coord| ~ 1          max abs err = 0.0004   (0.0445%)
  |coord| ~ 10         max abs err = 0.0030   (0.0297%)
  |coord| ~ 100        max abs err = 0.0093   (0.0093%)
  |coord| ~ 1000       max abs err = 0.4080   (0.0408%)
  |coord| ~ 10000      max abs err = 3.2080   (0.0321%)
SPEED  full   109.0 ns / encode+decode   9.17 M ops/s   (checksum 37050000.0)
SPEED  half   66.2 ns / encode+decode   15.11 M ops/s   (checksum 37050000.0)


=== Property-list gather: forced value copy vs reference (min of alternating rounds) ===
props=3   copy=  41.9 ns  ref=   4.2 ns  saved= 37.6 ns/gather (90%)  [sink=60000000]
props=8   copy= 100.0 ns  ref=   9.3 ns  saved= 90.7 ns/gather (91%)  [sink=160000000]


=== Full sync tick: gather+get_state+encode for N synchronizers (min of alternating rounds) ===
    copy = old value-returning gather (~master), ref = new reference gather; socket I/O excluded.
N=100     copy=    0.024 ms  ref=    0.020 ms  saved=   0.004 ms (16.7%)  per-node=40.0 ns  [sink=48000]
N=1000    copy=    0.245 ms  ref=    0.215 ms  saved=   0.030 ms (12.2%)  per-node=30.0 ns  [sink=480000]
N=10000   copy=    2.652 ms  ref=    2.215 ms  saved=   0.437 ms (16.5%)  per-node=43.7 ns  [sink=4800000]
N=50000   copy=   14.621 ms  ref=   12.766 ms  saved=   1.855 ms (12.7%)  per-node=37.1 ns  [sink=24000000]
N=100000  copy=   28.979 ms  ref=   25.135 ms  saved=   3.844 ms (13.3%)  per-node=38.4 ns  [sink=48000000]


=== Reduced-precision encode tick: per-property find (old) vs cached (new), min of alternating rounds ===
props=8    find+encode=   287.9 ns  cached+encode=    64.5 ns  saved=  223.4 ns/tick (78%)  [sink=112000000]
props=32   find+encode=  3448.9 ns  cached+encode=   242.4 ns  saved= 3206.5 ns/tick (93%)  [sink=448000000]
props=64   find+encode= 13510.5 ns  cached+encode=   483.6 ns  saved=13026.9 ns/tick (96%)  [sink=896000000]

===============================================================================
[doctest] test cases: 4 | 4 passed | 0 failed | 1275 skipped
[doctest] assertions: 1 | 1 passed | 0 failed |
[doctest] Status: SUCCESS!

Running the attached gdscript benchmark:

Engine:   26.3-alpha.1 (mcdubh-dev)
Mode:     FULL
SIZE:     56.20 bytes/entity-frame  (606960 bytes, 720 packets, 0 failed)
ACCURACY: position  max 0.00000  mean 0.00000  units
          rotation  max 0.07913  mean 0.01074  degrees
          scale     max 0.00000  mean 0.00000  units

Mode:     HALF
SIZE:     29.10 bytes/entity-frame  (314280 bytes, 360 packets, 0 failed)
ACCURACY: position  max 0.03697  mean 0.02118  units
          rotation  max 0.10468  mean 0.03989  degrees
          scale     max 0.00117  mean 0.00076  units

TL;DR on benchmarks:

PRECISION_HALF roughly halves replication bandwidth, is ~1.6 times faster {en,de}code, with small but magnitude-dependent precision loss. Cached precision lookup cuts reduced-precision encoding cost by 78–96%, while reference-based property gathering improves full sync ticks by ~12–17% for all replication modes.

Bandwidth
Native: 56 -> 23 bytes.
GDScript: 56.2 -> 29.1 bytes

Encoding Speed
Full: 109ns
Half: 66.2ns

Cached Precision Lookup
8 props: 78% faster
32 props: 93% faster
64 proprs: 96% faster

GDScript Accuracy
POS: 0.037 units
ROT: 0.105 degrees
SCALE: 0.0012 units

O-Notation Changes

Changing copy to ref resulted in:
Property-list gather: O(n) -> O(1)

searching the property list for every property vs cached lookup resulted in
Precision lookup and encoding: O(n^2) -> O(n)

How to use quantization

Quantization is accessible via the setting SceneReplicationConfig.PRECISION_HALF

For example:

var config := $MultiplayerSynchronizer.replication_config
config.property_set_precision(^".:position", SceneReplicationConfig.PRECISION_HALF)

Attached is a GDScript benchmark of FULL vs HALF. Be sure to change "USE_HALF" as needed for testing.

bench.zip

Summary by CodeRabbit

  • New Features
    • Added configurable full- and half-precision modes for multiplayer property replication.
    • Added editor controls for selecting each property’s replication precision.
    • Added reduced-precision synchronization for supported floats, vectors, quaternions, and colors.
    • Unsupported property types automatically use the existing serialization method.
  • Documentation
    • Documented precision settings, supported types, and configuration requirements.
  • Tests
    • Added coverage for precision round trips, mixed settings, fallback behavior, errors, and replication deltas.

@mcdubhghlas
mcdubhghlas requested review from a team August 7, 2026 16:17
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 413ae452-7b91-4c11-97fb-028631907028

📥 Commits

Reviewing files that changed from the base of the PR and between 9424667 and afbdb08.

📒 Files selected for processing (2)
  • modules/multiplayer/multiplayer_synchronizer.cpp
  • modules/multiplayer/tests/test_scene_replication.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • modules/multiplayer/tests/test_scene_replication.h
  • modules/multiplayer/multiplayer_synchronizer.cpp

Walkthrough

Scene replication adds per-property Full/Half precision settings. Quantized encoding and decoding support selected numeric Variants across spawn, delta, and synchronization packets. The editor, documentation, tests, and benchmarks expose and validate the new behavior.

Changes

Reduced-Precision Scene Replication

Layer / File(s) Summary
Precision configuration and editor wiring
modules/multiplayer/scene_replication_config.*, modules/multiplayer/doc_classes/SceneReplicationConfig.xml, modules/multiplayer/editor/replication_editor.*
Scene replication stores per-property precision, exposes scripting APIs, tracks channel precision metadata, and adds undoable Full/Half controls to the editor.
Quantized state codec
modules/multiplayer/multiplayer_synchronizer.*
MultiplayerSynchronizer encodes and decodes supported values with half-float components and uses compressed Variant serialization for unsupported values.
Replication packet integration
modules/multiplayer/scene_replication_interface.cpp
Spawn, delta, and synchronization packet paths select quantized or standard serialization from configured precision metadata.
Codec and replication validation
modules/multiplayer/tests/test_scene_replication.h, modules/multiplayer/tests/test_scene_replication_benchmark.h
Tests cover round trips, fallback, errors, cached precision alignment, and delta replication. Skipped benchmarks measure size, accuracy, throughput, property access, and cached precision lookup.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SceneReplicationConfig
  participant SceneReplicationInterface
  participant MultiplayerSynchronizer
  participant ReplicationPeer
  SceneReplicationConfig->>SceneReplicationInterface: provide channel precision vectors
  SceneReplicationInterface->>MultiplayerSynchronizer: encode state with precisions
  MultiplayerSynchronizer->>ReplicationPeer: write quantized or compressed payload
  ReplicationPeer->>MultiplayerSynchronizer: provide received payload
  MultiplayerSynchronizer->>SceneReplicationInterface: decode state with precisions
  SceneReplicationInterface->>ReplicationPeer: apply replication state
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: optional FP16 quantization and reduced replication overhead.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
modules/multiplayer/scene_replication_config.cpp (1)

250-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make property_get_precision a const method.

property_get_precision only reads properties. It does not call _update(). The sibling getters property_get_spawn and property_get_replication_mode are const, and their documentation entries use qualifiers="const". The new getter breaks that convention, and SceneReplicationConfig.xml declares property_get_precision without qualifiers="const" as a result. Add const in the header and the definition, then add qualifiers="const" to the doc entry.

♻️ Proposed change
-SceneReplicationConfig::ReplicationPrecision SceneReplicationConfig::property_get_precision(const NodePath &p_path) {
+SceneReplicationConfig::ReplicationPrecision SceneReplicationConfig::property_get_precision(const NodePath &p_path) const {
 	List<ReplicationProperty>::Element *E = properties.find(p_path);
 	ERR_FAIL_COND_V(!E, PRECISION_FULL);
 	return E->get().precision;
 }

Note: properties.find() on a const List returns a const Element *, so adjust the local type accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/multiplayer/scene_replication_config.cpp` around lines 250 - 254,
Make SceneReplicationConfig::property_get_precision const in both its
declaration and definition, and use a const List<ReplicationProperty>::Element
pointer for the properties.find result. Update the corresponding
SceneReplicationConfig.xml documentation entry to include qualifiers="const",
matching the sibling getter conventions.
modules/multiplayer/tests/test_scene_replication.h (1)

107-129: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a test for values outside the half-float range.

The current cases use magnitudes that fp16 represents exactly. SceneReplicationConfig.xml states that PRECISION_HALF reduces range, but no test pins that behavior.

Math::make_half_float maps any magnitude above 65504 to infinity, and it flushes very small magnitudes to zero. A game that replicates a position on a large map therefore receives inf rather than a clamped value. Add a case that encodes such a value and asserts the observed result. The test then documents the boundary and catches a future change in the conversion helper.

💚 Proposed test
+TEST_CASE("[Multiplayer][SceneReplication] Half precision saturates outside the fp16 range") {
+	Vector<Variant> values;
+	values.push_back(Vector3(70000.0, 1.0, 0.0000001));
+	Vector<int> precisions = { SceneReplicationConfig::PRECISION_HALF };
+
+	Vector<Variant> out = _round_trip(values, precisions);
+	const Vector3 v = out[0];
+	CHECK(Math::is_inf(v.x));
+	CHECK(v.y == doctest::Approx(1.0));
+	CHECK(v.z == doctest::Approx(0.0));
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/multiplayer/tests/test_scene_replication.h` around lines 107 - 129,
Add an out-of-range half-precision case to the “[Multiplayer][SceneReplication]
Half-precision codec round-trips supported types” test, covering a magnitude
above 65504 and asserting that the round-trip result is infinity as produced by
Math::make_half_float. Also cover a sufficiently small magnitude if needed to
document its flush-to-zero behavior, while preserving the existing
supported-type assertions.
modules/multiplayer/tests/test_scene_replication_benchmark.h (1)

113-121: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Size the encode buffer from the sizing pass.

buf is fixed at 128 bytes, and line 121 and line 127 encode into it without a sizing pass. encode_state_quantized never bounds-checks p_buffer; it writes r_len bytes and reports the total through r_len.

The current state encodes to about 68 bytes at full precision, so the buffer holds today. The margin is invisible at the call site. Any future field added to state at lines 63-68 overflows the heap buffer with no diagnostic.

Call the sizing pass first and resize, as the _encode helper at lines 48-60 already does. The same pattern applies at line 293 and line 319, where buf is sized by the formula prop_count * 8 + 16.

🛡️ Proposed change
 	Vector<uint8_t> buf;
-	buf.resize(128);
+	{
+		int max_size = 0;
+		MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), full.ptr(), ptrs.size(), nullptr, max_size, false);
+		buf.resize(max_size);
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/multiplayer/tests/test_scene_replication_benchmark.h` around lines
113 - 121, Replace the fixed 128-byte initialization of buf in the benchmark
encoding loop with the existing sizing-pass pattern used by the _encode helper:
call encode_state_quantized first to obtain the required length, resize buf
accordingly, then perform the actual encode for each precision mode. Apply the
same sizing-based allocation to the analogous buf usage around the later encode
calls instead of relying on prop_count * 8 + 16.
modules/multiplayer/multiplayer_synchronizer.cpp (1)

301-327: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document or drop the 0x3F type mask, and consider validating the count.

Two points on decode_state_quantized:

  1. Line 307 masks the type byte with 0x3F. encode_state_quantized writes the raw Variant::Type byte with no flag bits, so the mask is not required here. The mask mirrors the compressed-variant header layout in MultiplayerAPI. Add a short comment that states why the mask exists, or remove it. Without a comment, a later reader can assume the quantized header carries flags.

  2. The function derives the element count from r_variants.size() but indexes p_precisions[i] over the same range. The array length is not passed. Callers must guarantee that p_precisions holds at least r_variants.size() entries. The delta receive path builds these two arrays from separate sources, so the guarantee is not local. See the related comment on modules/multiplayer/scene_replication_interface.cpp.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/multiplayer/multiplayer_synchronizer.cpp` around lines 301 - 327,
Update decode_state_quantized around the type extraction and iteration bounds:
remove the unnecessary 0x3F mask or add a concise comment documenting the
quantized header’s flag layout, and validate that p_precisions contains at least
r_variants.size() entries before indexing it. Preserve the existing decoding
behavior while making the count guarantee explicit, coordinating with the delta
receive path if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@modules/multiplayer/scene_replication_interface.cpp`:
- Around line 731-743: Bound the iteration in _delta_precisions to at most 64
watched properties before evaluating 1ULL << i, while preserving the existing
precision collection and reduced result behavior. Apply the same 64-property
bound in MultiplayerSynchronizer::get_delta_properties so both functions produce
matching property counts for decode_state_quantized.
- Around line 952-962: Update on_sync_receive after the decode_state_quantized
or decode_and_decompress_variants call to verify that decoding succeeded and
consumed exactly size bytes, matching the validation used by on_delta_receive.
Reject the payload and report the decode error when consumed differs from size
before applying the decoded vars through set_state.

---

Nitpick comments:
In `@modules/multiplayer/multiplayer_synchronizer.cpp`:
- Around line 301-327: Update decode_state_quantized around the type extraction
and iteration bounds: remove the unnecessary 0x3F mask or add a concise comment
documenting the quantized header’s flag layout, and validate that p_precisions
contains at least r_variants.size() entries before indexing it. Preserve the
existing decoding behavior while making the count guarantee explicit,
coordinating with the delta receive path if needed.

In `@modules/multiplayer/scene_replication_config.cpp`:
- Around line 250-254: Make SceneReplicationConfig::property_get_precision const
in both its declaration and definition, and use a const
List<ReplicationProperty>::Element pointer for the properties.find result.
Update the corresponding SceneReplicationConfig.xml documentation entry to
include qualifiers="const", matching the sibling getter conventions.

In `@modules/multiplayer/tests/test_scene_replication_benchmark.h`:
- Around line 113-121: Replace the fixed 128-byte initialization of buf in the
benchmark encoding loop with the existing sizing-pass pattern used by the
_encode helper: call encode_state_quantized first to obtain the required length,
resize buf accordingly, then perform the actual encode for each precision mode.
Apply the same sizing-based allocation to the analogous buf usage around the
later encode calls instead of relying on prop_count * 8 + 16.

In `@modules/multiplayer/tests/test_scene_replication.h`:
- Around line 107-129: Add an out-of-range half-precision case to the
“[Multiplayer][SceneReplication] Half-precision codec round-trips supported
types” test, covering a magnitude above 65504 and asserting that the round-trip
result is infinity as produced by Math::make_half_float. Also cover a
sufficiently small magnitude if needed to document its flush-to-zero behavior,
while preserving the existing supported-type assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 23ff3489-79ac-4343-bdbc-a35f29eda126

📥 Commits

Reviewing files that changed from the base of the PR and between c45bee3 and b3325db.

📒 Files selected for processing (10)
  • modules/multiplayer/doc_classes/SceneReplicationConfig.xml
  • modules/multiplayer/editor/replication_editor.cpp
  • modules/multiplayer/editor/replication_editor.h
  • modules/multiplayer/multiplayer_synchronizer.cpp
  • modules/multiplayer/multiplayer_synchronizer.h
  • modules/multiplayer/scene_replication_config.cpp
  • modules/multiplayer/scene_replication_config.h
  • modules/multiplayer/scene_replication_interface.cpp
  • modules/multiplayer/tests/test_scene_replication.h
  • modules/multiplayer/tests/test_scene_replication_benchmark.h

Comment thread modules/multiplayer/scene_replication_interface.cpp
Comment thread modules/multiplayer/scene_replication_interface.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@modules/multiplayer/tests/test_scene_replication.h`:
- Around line 169-170: Update the overflow assertions in the scene replication
test to require v.x to be positive infinity and v.y to be negative infinity,
using exact signed-infinity checks instead of accepting arbitrary infinity or
NaN values. Preserve the existing codec round-trip setup and assert the
documented signed overflow results for both components.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c26ea4f5-1fd7-4b5e-a22d-7c2967c33d61

📥 Commits

Reviewing files that changed from the base of the PR and between b3325db and 9424667.

📒 Files selected for processing (3)
  • modules/multiplayer/scene_replication_interface.cpp
  • modules/multiplayer/tests/test_scene_replication.h
  • modules/multiplayer/tests/test_scene_replication_benchmark.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • modules/multiplayer/tests/test_scene_replication_benchmark.h
  • modules/multiplayer/scene_replication_interface.cpp

Comment thread modules/multiplayer/tests/test_scene_replication.h Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants