From 65a1d2558642771d3366f997e060fdfbe2d91871 Mon Sep 17 00:00:00 2001 From: Loretta Date: Sun, 4 Jan 2026 20:14:41 +0100 Subject: [PATCH] Optimize binary serialization with PropertySkip marker Refactored object serialization to use fixed property order and a new PropertySkip marker for default/null values, removing property count and index overhead. Updated deserialization logic to handle skip markers and set defaults efficiently. Added WritePropertyOrSkip and SetPropertyToDefault methods for single-pass, boxing-free property handling. SkipObject now throws for new format. Updated BinaryTypeCode, .gitignore, and settings.local.json. --- .claude/settings.local.json | 16 +- .gitignore | 3 + .../Binaries/AcBinaryDeserializer.Populate.cs | 143 ++++++++++--- .../Binaries/AcBinaryDeserializer.cs | 18 +- .../Binaries/AcBinarySerializer.cs | 198 ++++++++++++++++-- .../Binaries/AcBinarySerializerOptions.cs | 3 + 6 files changed, 314 insertions(+), 67 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c1e4355..8866d3a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,21 @@ "Bash(findstr:*)", "Bash(cmd /c \"cd /d H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark && dir /B /O-D *.log\")", "Bash(ls:*)", - "Bash(git checkout:*)" + "Bash(git checkout:*)", + "Bash(dir /B \"H:\\Applications\\Aycode\\Source\\AyCode.Core\\AyCode.Benchmark\\*.cs\")", + "Bash(taskkill:*)", + "Bash(cmd /c \"dir /B /O-D *.log\")", + "Bash(printf:*)", + "Bash(dir /B /O-D \"H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log\")", + "Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log\")", + "Bash(powershell:*)", + "Bash(timeout /t 60 /nobreak)", + "Bash(grep:*)", + "Bash(timeout /t 3 /nobreak)", + "Bash(dir /B /O-D \"H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.md\")", + "Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.md\")", + "Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log 2>nul | head -3\")", + "Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log 2>nul | head -1\")" ] } } diff --git a/.gitignore b/.gitignore index c70e59d..172eb08 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ bin/ /.vs/* /.vs/** +/.claude/* +/.claude/** + # User-specific files *.rsuser *.suo diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.Populate.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.Populate.cs index a2e0c14..bdb7a1b 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.Populate.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.Populate.cs @@ -26,30 +26,41 @@ public static partial class AcBinaryDeserializer PopulateObject(ref context, target, metadata, depth); } - private static void PopulateObject(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth) + private static void PopulateObject(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth, bool skipDefaultWrite = false) { - var propertyCount = (int)context.ReadVarUInt(); + // NEW FORMAT: Fixed property order with SKIP markers + // - No property count (iterate all properties in metadata order) + // - No property indices (use sequential PropertyIndex = i) + // - PropertySkip marker indicates default/null value + // + // skipDefaultWrite: + // true = Deserialize mode - object just created, properties already at default, skip writing + // false = Populate mode - existing object, must overwrite properties with default values + + var properties = metadata.PropertiesArray; var nextDepth = depth + 1; - for (int i = 0; i < propertyCount; i++) + for (int i = 0; i < properties.Length; i++) { - var propertyIndexStartPosition = context.Position; - - // Read property index directly - no string lookup needed! - var propIndex = (int)context.ReadVarUInt(); - - // O(1) array lookup instead of dictionary lookup - var propInfo = metadata.GetPropertyByIndex(propIndex); - - if (propInfo == null) + var propInfo = properties[i]; + + var peekCode = context.PeekByte(); + + // OPTIMIZATION: Skip marker - property has default/null value + if (peekCode == BinaryTypeCode.PropertySkip) { - // Skip unknown property (schema evolution - new property on sender side) - SkipValue(ref context); + context.ReadByte(); // consume Skip marker + + // Populate mode: overwrite with default (existing object may have non-default values) + // Deserialize mode: skip write (new object already has defaults from CreateInstance) + if (!skipDefaultWrite) + { + SetPropertyToDefault(target, propInfo); + } + continue; } - var peekCode = context.PeekByte(); - // OPTIMIZATION: Skip null values early - no GetValue call needed! if (peekCode == BinaryTypeCode.Null) { @@ -91,7 +102,7 @@ public static partial class AcBinaryDeserializer // OPTIMIZATION: Use typed setters for primitives to avoid boxing if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode)) continue; - + var value = ReadValue(ref context, propInfo.PropertyType, nextDepth); propInfo.SetValue(target, value); } @@ -100,9 +111,9 @@ public static partial class AcBinaryDeserializer // Only get type info when needed for error message (cold path) var targetType = target.GetType(); var targetTypeName = targetType.Name; - + throw new AcBinaryDeserializationException( - $"Type mismatch for property '{propInfo.Name}' (index {i}/{propertyCount}, propIndex={propIndex}) on '{targetTypeName}'. " + + $"Type mismatch for property '{propInfo.Name}' (index {i}) on '{targetTypeName}'. " + $"Expected type: '{propInfo.PropertyType.FullName}'. " + $"PeekCode before read: {peekCode} (0x{peekCode:X2}). " + $"Position before read: {positionBeforeRead}, current: {context.Position}. " + @@ -137,7 +148,7 @@ public static partial class AcBinaryDeserializer PopulateObject(ref context, target, metadata, depth); } - private static void PopulateObjectMerge(ref BinaryDeserializationContext context, object target, Type targetType, int depth) + private static void PopulateObjectMerge(ref BinaryDeserializationContext context, object target, Type targetType, int depth, bool skipDefaultWrite = false) { var metadata = GetTypeMetadata(targetType); @@ -151,23 +162,31 @@ public static partial class AcBinaryDeserializer } } - var propertyCount = (int)context.ReadVarUInt(); + // NEW FORMAT: Fixed property order with SKIP markers + var properties = metadata.PropertiesArray; var nextDepth = depth + 1; - for (int i = 0; i < propertyCount; i++) + for (int i = 0; i < properties.Length; i++) { - // Read property index directly - O(1) array lookup - var propIndex = (int)context.ReadVarUInt(); - var propInfo = metadata.GetPropertyByIndex(propIndex); - - if (propInfo == null) - { - SkipValue(ref context); - continue; - } + var propInfo = properties[i]; var peekCode = context.PeekByte(); + // OPTIMIZATION: Skip marker - property has default/null value + if (peekCode == BinaryTypeCode.PropertySkip) + { + context.ReadByte(); // consume Skip marker + + // Populate mode: overwrite with default (same as PopulateObject) + // Deserialize mode: skip write (new object already has defaults) + if (!skipDefaultWrite) + { + SetPropertyToDefault(target, propInfo); + } + + continue; + } + // Handle IId collection merge if (propInfo.IsIIdCollection && peekCode == BinaryTypeCode.Array) { @@ -315,7 +334,8 @@ public static partial class AcBinaryDeserializer } } - PopulateObject(ref context, existingItem, elementMetadata, nextDepth); + // Populate mode: existing item, must overwrite with defaults + PopulateObject(ref context, existingItem, elementMetadata, nextDepth, skipDefaultWrite: false); continue; } } @@ -415,7 +435,8 @@ public static partial class AcBinaryDeserializer context.RegisterObject(refId, newItem); } - PopulateObject(ref context, newItem, elementMetadata, nextDepth); + // Deserialize mode: new item just created, skip writing defaults + PopulateObject(ref context, newItem, elementMetadata, nextDepth, skipDefaultWrite: true); var itemId = idGetter(newItem); if (itemId != null && !IsDefaultValue(itemId, idType)) @@ -527,7 +548,8 @@ public static partial class AcBinaryDeserializer context.RegisterObject(refId, newItem); } - PopulateObject(ref context, newItem, elementMetadata, nextDepth); + // Deserialize mode: new item just created, skip writing defaults + PopulateObject(ref context, newItem, elementMetadata, nextDepth, skipDefaultWrite: true); var itemId = idGetter(newItem); if (itemId != null && !IsDefaultValue(itemId, idType)) @@ -584,6 +606,59 @@ public static partial class AcBinaryDeserializer } } + /// + /// Sets a property to its default value using typed setters to avoid boxing. + /// For collections and complex objects, clears the collection instead of setting to null. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetPropertyToDefault(object target, BinaryPropertySetterInfo propInfo) + { + switch (propInfo.SetterType) + { + case PropertyAccessorType.Int32: + propInfo.SetInt32(target, 0); + return; + case PropertyAccessorType.Int64: + propInfo.SetInt64(target, 0L); + return; + case PropertyAccessorType.Boolean: + propInfo.SetBoolean(target, false); + return; + case PropertyAccessorType.Double: + propInfo.SetDouble(target, 0.0); + return; + case PropertyAccessorType.Single: + propInfo.SetSingle(target, 0f); + return; + case PropertyAccessorType.Decimal: + propInfo.SetDecimal(target, 0m); + return; + case PropertyAccessorType.DateTime: + propInfo.SetDateTime(target, default); + return; + case PropertyAccessorType.Guid: + propInfo.SetGuid(target, default); + return; + case PropertyAccessorType.Object: + default: + // For collections: clear instead of setting to null + if (propInfo.IsCollection) + { + var collection = propInfo.GetValue(target); + if (collection is IList list) + { + list.Clear(); + } + // If not IList or null, leave it as-is (readonly collection or null already) + return; + } + + // For other objects: set to null (strings, nullable types, etc.) + propInfo.SetValue(target, null); + return; + } + } + /// /// Determines if a type is a complex type (not primitive, string, or simple value type). /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs index f92d554..46fc890 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs @@ -927,7 +927,8 @@ public static partial class AcBinaryDeserializer context.RegisterObject(refId, instance); } - PopulateObject(ref context, instance, metadata, depth); + // Deserialize mode: object just created, skip writing defaults (already at default) + PopulateObject(ref context, instance, metadata, depth, skipDefaultWrite: true); // ChainMode: Check if we already have an object with this Id in the tracker // Use cached IdType from metadata instead of id.GetType() to avoid reflection overhead @@ -1316,15 +1317,12 @@ public static partial class AcBinaryDeserializer context.ReadVarInt(); } - var propCount = (int)context.ReadVarUInt(); - for (int i = 0; i < propCount; i++) - { - // Skip property index (VarUInt) - context.ReadVarUInt(); - - // Skip value - SkipValue(ref context); - } + // NEW FORMAT: Can't skip without knowing property count! + // Need to read type metadata to know how many properties to skip + // For now, throw exception - SkipObject not supported with new format + throw new NotSupportedException( + "SkipObject is not supported with SKIP marker format. " + + "Unable to determine property count without type metadata."); } private static void SkipArray(ref BinaryDeserializationContext context) diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index a0aa98f..b23fe23 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -712,37 +712,26 @@ public static partial class AcBinarySerializer var properties = metadata.Properties; var propCount = properties.Length; - // Reserve space for property count (will patch later) - var countPosition = context.Position; - context.WriteVarUInt(0); // Placeholder - will be patched - - var writtenCount = 0; + // Single-pass serialization with SKIP markers + // - No property count needed (fixed property order) + // - No property indices needed (sequential order) + // - Single getter call per property + // - Write value OR skip marker in one operation - // Single pass: check and write in one iteration for (var i = 0; i < propCount; i++) { var prop = properties[i]; - // Skip if filter says no + // Skip if filter says no - write skip marker if (context.PropertyFilter != null && !context.ShouldSerializeProperty(value, prop)) + { + context.WriteByte(BinaryTypeCode.PropertySkip); continue; + } - // Skip default/null values - if (IsPropertyDefaultOrNull(value, prop)) - continue; - - // Write property index directly - no dictionary lookup needed! - // PropertyIndex is deterministic (based on alphabetical ordering) - // and consistent across all platforms (WASM, iOS, Android, Windows) - context.WriteVarUInt((uint)prop.PropertyIndex); - - // Write property value - WritePropertyValue(value, prop, context, nextDepth); - writtenCount++; + // Write property value OR skip marker (single operation, single getter call) + WritePropertyOrSkip(value, prop, context, nextDepth); } - - // Patch the property count - context.PatchVarUInt(countPosition, (uint)writtenCount); } /// @@ -861,6 +850,171 @@ public static partial class AcBinarySerializer } } + /// + /// Writes a property value OR a skip marker if the value is default/null. + /// Single-pass optimization: checks default + writes value in one operation. + /// Avoids double getter calls. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WritePropertyOrSkip(object obj, BinaryPropertyAccessor prop, BinarySerializationContext context, int depth) + { + switch (prop.AccessorType) + { + case PropertyAccessorType.Int32: + { + int value = prop.GetInt32(obj); + if (value == 0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteInt32(value, context); + return; + } + case PropertyAccessorType.Int64: + { + long value = prop.GetInt64(obj); + if (value == 0L) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteInt64(value, context); + return; + } + case PropertyAccessorType.Boolean: + { + bool value = prop.GetBoolean(obj); + if (!value) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + context.WriteByte(BinaryTypeCode.True); + return; + } + case PropertyAccessorType.Double: + { + double value = prop.GetDouble(obj); + if (value == 0.0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteFloat64Unsafe(value, context); + return; + } + case PropertyAccessorType.Single: + { + float value = prop.GetSingle(obj); + if (value == 0f) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteFloat32Unsafe(value, context); + return; + } + case PropertyAccessorType.Decimal: + { + decimal value = prop.GetDecimal(obj); + if (value == 0m) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteDecimalUnsafe(value, context); + return; + } + case PropertyAccessorType.DateTime: + { + DateTime value = prop.GetDateTime(obj); + // DateTime always written (no default skip) + WriteDateTimeUnsafe(value, context); + return; + } + case PropertyAccessorType.Byte: + { + byte value = prop.GetByte(obj); + if (value == 0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + { + context.WriteByte(BinaryTypeCode.UInt8); + context.WriteByte(value); + } + return; + } + case PropertyAccessorType.Int16: + { + short value = prop.GetInt16(obj); + if (value == 0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteInt16Unsafe(value, context); + return; + } + case PropertyAccessorType.UInt16: + { + ushort value = prop.GetUInt16(obj); + if (value == 0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteUInt16Unsafe(value, context); + return; + } + case PropertyAccessorType.UInt32: + { + uint value = prop.GetUInt32(obj); + if (value == 0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteUInt32(value, context); + return; + } + case PropertyAccessorType.UInt64: + { + ulong value = prop.GetUInt64(obj); + if (value == 0) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteUInt64(value, context); + return; + } + case PropertyAccessorType.Guid: + { + Guid value = prop.GetGuid(obj); + if (value == Guid.Empty) + context.WriteByte(BinaryTypeCode.PropertySkip); + else + WriteGuidUnsafe(value, context); + return; + } + case PropertyAccessorType.Enum: + { + int enumValue = prop.GetEnumAsInt32(obj); + if (enumValue == 0) + { + context.WriteByte(BinaryTypeCode.PropertySkip); + } + else if (BinaryTypeCode.TryEncodeTinyInt(enumValue, out var tiny)) + { + context.WriteByte(BinaryTypeCode.Enum); + context.WriteByte(tiny); + } + else + { + context.WriteByte(BinaryTypeCode.Enum); + context.WriteByte(BinaryTypeCode.Int32); + context.WriteVarInt(enumValue); + } + return; + } + default: + { + // Object type - use regular getter + var value = prop.GetValue(obj); + if (value == null || (prop.PropertyTypeCode == TypeCode.String && string.IsNullOrEmpty((string)value))) + { + context.WriteByte(BinaryTypeCode.PropertySkip); + } + else + { + WriteValue(value, prop.PropertyType, context, depth); + } + return; + } + } + } + #endregion #region Specialized Array Writers diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs index 2b5f664..c2fa725 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs @@ -213,6 +213,9 @@ internal static class BinaryTypeCode // Compact integer variants (for VarInt optimization) public const byte Int32Tiny = 192; // -16 to 63 stored in single byte (value = code - 192 - 16) public const byte Int32TinyMax = 255; // Upper bound for tiny int (192 + 64 - 1 = 255) + + // Property skip marker (for single-pass serialization optimization) + public const byte PropertySkip = 253; // Marks a property with default/null value (skipped during serialization) /// /// Check if type code represents a reference (string or object).