From 3b11a376f07419680fa79941e12548160b530ed0 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:18:26 -0700 Subject: [PATCH] perf(saves): delete SerializedTypes.db write path and runtime type tracking The idx v4 type table supersedes the global type queue: workers no longer enqueue every Write(Type) into a shared ConcurrentQueue during the freeze, WriteFiles no longer drains and dedupes millions of entries, and the db file is no longer produced. Loading old saves still reads SerializedTypes.db via the legacy v2/v3 path. Co-Authored-By: Claude Fable 5 --- .../Serialization/FileBufferWriterTests.cs | 9 +--- .../GenericEntityPersistenceRoundTripTests.cs | 2 +- .../Server/Serialization/AdhocPersistence.cs | 18 ++----- Projects/Server/Serialization/BufferWriter.cs | 11 ++--- .../Server/Serialization/FileBufferWriter.cs | 16 +++--- .../Serialization/GenericEntityPersistence.cs | 4 +- .../Serialization/GenericPersistence.cs | 2 +- Projects/Server/Serialization/Persistence.cs | 24 ++------- .../SerializationThreadWorker.cs | 5 +- Projects/Server/World/World.cs | 49 +------------------ 10 files changed, 26 insertions(+), 114 deletions(-) diff --git a/Projects/Server.Tests/Tests/Serialization/FileBufferWriterTests.cs b/Projects/Server.Tests/Tests/Serialization/FileBufferWriterTests.cs index df3f52f0e..fcbfb70a0 100644 --- a/Projects/Server.Tests/Tests/Serialization/FileBufferWriterTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/FileBufferWriterTests.cs @@ -111,23 +111,18 @@ public class FileBufferWriterTests } [Fact] - public void TypeWritesRegisterIntoTheTypeSet() + public void TypeWritesUseTagAndHashFormat() { var path = TempFile(); try { - var typeSet = new System.Collections.Generic.HashSet(); - - using (var writer = new FileBufferWriter(path, typeSet)) + using (var writer = new FileBufferWriter(path)) { writer.Write(typeof(string)); writer.Write((Type)null); } - Assert.Contains(typeof(string), typeSet); - Assert.Single(typeSet); - var bytes = File.ReadAllBytes(path); Assert.Equal(1 + 8 + 1, bytes.Length); // flag + hash + null flag Assert.Equal(2, bytes[0]); diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs index 9faf986f9..f2114a427 100644 --- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs @@ -123,7 +123,7 @@ public class GenericEntityPersistenceRoundTripTests // Background write phase: snapshot from the segment logs. v4 embeds the type // table in the idx, so no SerializedTypes.db is produced or needed. - persistence.WriteSnapshot(dir, []); + persistence.WriteSnapshot(dir); persistence.PostWorldSave(); // releases the entries snapshot diff --git a/Projects/Server/Serialization/AdhocPersistence.cs b/Projects/Server/Serialization/AdhocPersistence.cs index b40b25b23..d6c132549 100644 --- a/Projects/Server/Serialization/AdhocPersistence.cs +++ b/Projects/Server/Serialization/AdhocPersistence.cs @@ -14,7 +14,6 @@ *************************************************************************/ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.IO.MemoryMappedFiles; @@ -27,9 +26,9 @@ public static class AdhocPersistence /** * Serializes to memory. */ - public static IGenericWriter SerializeToBuffer(Action serializer, ConcurrentQueue types = null) + public static IGenericWriter SerializeToBuffer(Action serializer) { - var saveBuffer = new BufferWriter(true, types); + var saveBuffer = new BufferWriter(true); serializer(saveBuffer); return saveBuffer; } @@ -55,18 +54,11 @@ public static class AdhocPersistence { var fullPath = PathUtility.GetFullPath(filePath, Core.BaseDirectory); PathUtility.EnsureDirectory(Path.GetDirectoryName(fullPath)); - HashSet typesSet = []; - var writer = new FileBufferWriter(fullPath, typesSet, sizeHint); + + var writer = new FileBufferWriter(fullPath, sizeHint); serializer(writer); - Task.Run( - () => - { - writer.Dispose(); - Persistence.WriteSerializedTypesSnapshot(Path.GetDirectoryName(fullPath), typesSet); - }, - Core.ClosingTokenSource.Token - ); + Task.Run(() => writer.Dispose(), Core.ClosingTokenSource.Token); } public static unsafe void Deserialize(string filePath, Action deserializer) diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index ab441a71c..b811d68cb 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -17,7 +17,6 @@ using System; using System.Buffers; using System.Buffers.Binary; using System.Collections; -using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.Net; @@ -30,7 +29,6 @@ namespace Server; public class BufferWriter : IGenericWriter { - private readonly ConcurrentQueue _types; private readonly Encoding _encoding; private readonly bool _prefixStrings; @@ -60,24 +58,22 @@ public class BufferWriter : IGenericWriter private byte[] _buffer; - public BufferWriter(byte[] buffer, bool prefixStr, ConcurrentQueue types = null) + public BufferWriter(byte[] buffer, bool prefixStr) { _prefixStrings = prefixStr; _encoding = TextEncoding.UTF8; _buffer = buffer; - _types = types; } - public BufferWriter(bool prefixStr, ConcurrentQueue types = null) : this(0, prefixStr, types) + public BufferWriter(bool prefixStr) : this(0, prefixStr) { } - public BufferWriter(int count, bool prefixStr, ConcurrentQueue types = null) + public BufferWriter(int count, bool prefixStr) { _prefixStrings = prefixStr; _encoding = TextEncoding.UTF8; _buffer = GC.AllocateUninitializedArray(count < 1 ? BufferSize : count); - _types = types; } public virtual long Position => _index; @@ -307,7 +303,6 @@ public class BufferWriter : IGenericWriter { Write((byte)0x2); // xxHash3 64bit Write(AssemblyHandler.GetTypeHash(type)); - _types?.Enqueue(type); } } diff --git a/Projects/Server/Serialization/FileBufferWriter.cs b/Projects/Server/Serialization/FileBufferWriter.cs index 8b5862403..c7408f7bc 100644 --- a/Projects/Server/Serialization/FileBufferWriter.cs +++ b/Projects/Server/Serialization/FileBufferWriter.cs @@ -15,8 +15,6 @@ using System; using System.Buffers; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.IO; using Microsoft.Win32.SafeHandles; @@ -44,16 +42,14 @@ public class FileBufferWriter : BufferWriter, IDisposable private long _fileHighWater; // logical end of file across seeks /// Destination file; created/truncated. - /// Types written via register here. /// - /// Expected total file size when known (e.g. idx files are exactly - /// 8 + 33 * count bytes). Files at or under the staging cap never drain until close; - /// larger files stream through a pooled block at the cap. The block comes from - /// ArrayPool so sequential snapshot writers recycle one buffer instead of dropping - /// a large-object allocation per file per save. + /// Expected total file size when known. Files at or under the staging cap never + /// drain until close; larger files stream through a pooled block at the cap. The + /// block comes from ArrayPool so sequential snapshot writers recycle one buffer + /// instead of dropping a large-object allocation per file per save. /// - public FileBufferWriter(string filePath, HashSet typeSet = null, long expectedSize = MaxStagingSize) - : base(RentStaging(expectedSize), true, typeSet != null ? new ConcurrentQueue(typeSet) : null) + public FileBufferWriter(string filePath, long expectedSize = MaxStagingSize) + : base(RentStaging(expectedSize), true) { _rentedStaging = Buffer; _handle = File.OpenHandle(filePath, FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.SequentialScan); diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 5ee60c5e4..48288fde4 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -102,7 +102,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer typeof(T).RegisterFindEntity(Find); } - public override void WriteSnapshot(string savePath, HashSet typeSet) + public override void WriteSnapshot(string savePath) { var dir = Path.Combine(savePath, Name); PathUtility.EnsureDirectory(dir); @@ -117,7 +117,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer // v4 records are fixed-width 26 bytes; the header carries the type table // (name lengths vary — 64 bytes per entry is a staging hint, not a contract). var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; - using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), typeSet, expectedIdxSize); + using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index f1c939729..5b8e79c29 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -57,7 +57,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable World.PushSingleToCache(this); } - public override void WriteSnapshot(string savePath, HashSet typeSet) + public override void WriteSnapshot(string savePath) { if (_selfLength == 0) { diff --git a/Projects/Server/Serialization/Persistence.cs b/Projects/Server/Serialization/Persistence.cs index 1e55a0fc6..35d1d7514 100644 --- a/Projects/Server/Serialization/Persistence.cs +++ b/Projects/Server/Serialization/Persistence.cs @@ -84,29 +84,11 @@ public abstract class Persistence } // Note: This is strictly on a background thread - internal static void WriteSnapshotAll(string path, HashSet typeSet) + internal static void WriteSnapshotAll(string path) { foreach (var p in _registry) { - p.WriteSnapshot(path, typeSet); - } - - WriteSerializedTypesSnapshot(path, typeSet); - } - - public static void WriteSerializedTypesSnapshot(string path, HashSet types) - { - var typesPath = Path.Combine(path, "SerializedTypes.db"); - using var writer = new FileBufferWriter(typesPath); - - writer.Write(0); // version - writer.Write(types.Count); - - foreach (var type in types) - { - var fullName = type.FullName; - writer.Write(HashUtility.ComputeHash64(fullName)); - writer.WriteRaw(fullName); + p.WriteSnapshot(path); } } @@ -148,7 +130,7 @@ public abstract class Persistence } // Note: This should only be run on a background thread - public abstract void WriteSnapshot(string savePath, HashSet typeSet); + public abstract void WriteSnapshot(string savePath); public abstract void Serialize(); diff --git a/Projects/Server/Serialization/SerializationThreadWorker.cs b/Projects/Server/Serialization/SerializationThreadWorker.cs index c4aee0750..8fe78911d 100644 --- a/Projects/Server/Serialization/SerializationThreadWorker.cs +++ b/Projects/Server/Serialization/SerializationThreadWorker.cs @@ -214,7 +214,7 @@ public class SerializationThreadWorker { ReleaseWriteLogs(); - var writer = new BufferWriter(_heap, true, World.SerializedTypes); + var writer = new BufferWriter(_heap, true); var entities = 0L; while (_chunkSource.TryTake(out var chunk)) @@ -234,13 +234,12 @@ public class SerializationThreadWorker var worker = (SerializationThreadWorker)obj; var chunkSource = worker._chunkSource; - var serializedTypes = World.SerializedTypes; while (worker._startEvent.WaitOne()) { worker.ReleaseWriteLogs(); - var writer = new BufferWriter(worker._heap, true, serializedTypes); + var writer = new BufferWriter(worker._heap, true); var entities = 0L; var spinner = new SpinWait(); diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index af6eda095..2c9e6d216 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -14,7 +14,6 @@ *************************************************************************/ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -239,39 +238,6 @@ public static class World } } - /** - * Duplicates can be weeded out asynchronously while flushing - * If performance becomes a problem, we need to build a dual mode concurrent array. - * - ****************************************************** Proposal ****************************************************** - * The structure is initialized with a large capacity to avoid unnecessary resizing. - * Write Mode: - * - Multiple threads can add a single, or a range of elements concurrently. - * - Elements can be Peeked, but there are no guarantees. - * - To resize the internal array, replaced it with the next size up from an array pool. - * - The structure cannot be cleared in this mode. - * - * Read Mode: - * - The array can be read from multiple threads using a ref struct enumerator. - * - Elements cannot be added or reassigned. - * - Cleared by replacing the internal array with another one from the pool. - * - Note: Upon clearing, the existing array is not sent back to the pool until there are zero enumerators. - * - * Enumeration: - * - Multiple threads can enumerate while in read mode. The enumerator will Interlocked.Increment a read counter. - * - Upon dispose of the enumerator, the read counter will be lowered with an Interlocked.Decrement - * - When the read counter reaches 0, if there is a cleared array, the array is sent back to the pool zeroed. - * - * Notes: - * - Elements can never be removed. - * - * How is this different from ConcurrentQueue? - * The functionality is very similar, except the constraints allow the implementation to be done without locks. - * Since this implementation uses pooled arrays, allocations will approach zero over time. - ********************************************************************************************************************** - */ - public static ConcurrentQueue SerializedTypes { get; } = new(); - public static void Save() { if (WorldState != WorldState.Running) @@ -367,8 +333,6 @@ public static class World } } - private static readonly HashSet _typesSet = []; - private static void WriteFiles(object state) { var snapshotPath = (string)state; @@ -377,15 +341,7 @@ public static class World var watch = Stopwatch.StartNew(); logger.Information("Writing world save snapshot"); - // Dedupe the types - while (SerializedTypes.TryDequeue(out var type)) - { - _typesSet.Add(type); - } - - Persistence.WriteSnapshotAll(snapshotPath, _typesSet); - - _typesSet.Clear(); + Persistence.WriteSnapshotAll(snapshotPath); try { @@ -409,9 +365,6 @@ public static class World BroadcastStaff(0x35, true, "Writing world save snapshot failed! Check the logs!"); } - // Clear types - SerializedTypes.Clear(); - _diskWriteHandle.Set(); Core.LoopContext.Post(FinishWorldSave); }