From 9780fc6634c043f801385218b62d781a0b17f7a5 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:42:55 -0700 Subject: [PATCH] perf(saves): keep the fallback push loop branch-free Measured (10M entities through the real chunk source, both monomorphic and 16-subclass polymorphic populations): a bare `foreach { PushToCache(entity); }` runs at 2.3ns/entity while the same loop carrying the heavy-entity check (interface SerializedLength read + branch) runs at 5.3-5.7ns - 2.3x slower. Type diversity barely matters; the cost is the fatter loop body, confirming that per-entity logic in the push loop defeats the JIT's tight-loop codegen. Entities over 1MB are rare in practice - realistically only whole GenericPersistence self-payloads, which are already published as dedicated single chunks - so the fallback loop drops the check and rare thick entities ride inside shared chunks (bounded tail, same behavior as the slot-range fast path). The now-unused HeavyEntityThreshold constant is removed. Co-Authored-By: Claude Fable 5 --- .../SerializationChunkSourceTests.cs | 15 ++++++++------- .../Serialization/GenericEntityPersistence.cs | 16 ++++------------ .../Serialization/SerializationChunkSource.cs | 12 ++---------- 3 files changed, 14 insertions(+), 29 deletions(-) diff --git a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs index 6ddfae427..73a9d50eb 100644 --- a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs @@ -156,19 +156,20 @@ public class SerializationChunkSourceTests entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); } - // A heavy entity mid-stream gets a dedicated chunk - var heavy = new TestEntity { PayloadSize = 512 * 1024, SerializedLength = 4 * 1024 * 1024 }; - entities.Insert(5000, heavy); - foreach (var worker in workers) { worker.Wake(); } - // Mirrors GenericEntityPersistence.Serialize: heavy check at the call site - foreach (var e in entities) + // A large payload published as a dedicated single chunk (like persistence + // self-payloads), interleaved with the bare entity stream. + var heavy = new TestEntity { PayloadSize = 512 * 1024 }; + entities.Insert(5000, heavy); + + for (var i = 0; i < entities.Count; i++) { - if (e.SerializedLength > SerializationChunkSource.HeavyEntityThreshold) + var e = entities[i]; + if (e == heavy) { source.PushSingle(e); } diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 2b592d35e..5e3e35bdf 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -173,20 +173,12 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer return; } - // Fallback: enumerate and hand off every entity from the main thread. + // Fallback: enumerate and hand off every entity from the main thread. Kept branch-free: + // a bare loop is ~2.3x faster than one carrying per-entity logic, and multi-megabyte + // entities are rare enough that riding inside a shared chunk is an acceptable tail. foreach (var entity in EntitiesBySerial.Values) { - // Previous save's length is the cost estimate; 0 (new entity or first save) takes - // the small path. Heavy entities get dedicated chunks so multi-megabyte payloads - // spread across workers instead of riding inside one shared chunk. - if (entity.SerializedLength > SerializationChunkSource.HeavyEntityThreshold) - { - World.PushSingleToCache(entity); - } - else - { - World.PushToCache(entity); - } + World.PushToCache(entity); } } diff --git a/Projects/Server/Serialization/SerializationChunkSource.cs b/Projects/Server/Serialization/SerializationChunkSource.cs index 141200519..3b498b77f 100644 --- a/Projects/Server/Serialization/SerializationChunkSource.cs +++ b/Projects/Server/Serialization/SerializationChunkSource.cs @@ -41,9 +41,8 @@ public interface ISlotRangeSource /// so the per-entity cost is a plain array store instead of a synchronized enqueue, and /// workers pull whole chunks so they naturally load-balance: a worker busy with a thick /// entity simply takes fewer chunks. -/// Entities whose previous serialized size exceeds are -/// published as dedicated single-entity chunks so multi-megabyte payloads spread across -/// workers instead of riding inside one chunk. +/// Persistence self-payloads are published as dedicated single-entity chunks so large +/// systems spread across workers instead of riding inside one chunk. /// Persistences that support direct parallel iteration publish slot ranges instead of /// filled chunks, removing the per-entity handoff from the freeze entirely. /// @@ -53,13 +52,6 @@ public sealed class SerializationChunkSource // while the drain tail stays sub-millisecond. private const int ChunkCapacity = 4096; - /// - /// Entities whose previous exceeds this - /// should be pushed with . Callers do the check where the entity's - /// concrete type is known, so the size read is not an interface dispatch per entity. - /// - public const int HeavyEntityThreshold = 1024 * 1024; // 1MB - internal readonly struct Chunk { public readonly IGenericSerializable Single;