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.
This commit is contained in:
Loretta 2026-01-04 20:14:41 +01:00
parent 4b9e1490ef
commit 65a1d25586
6 changed files with 314 additions and 67 deletions

View File

@ -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\")"
]
}
}

3
.gitignore vendored
View File

@ -18,6 +18,9 @@ bin/
/.vs/*
/.vs/**
/.claude/*
/.claude/**
# User-specific files
*.rsuser
*.suo

View File

@ -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
}
}
/// <summary>
/// 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.
/// </summary>
[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;
}
}
/// <summary>
/// Determines if a type is a complex type (not primitive, string, or simple value type).
/// </summary>

View File

@ -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)

View File

@ -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);
}
/// <summary>
@ -861,6 +850,171 @@ public static partial class AcBinarySerializer
}
}
/// <summary>
/// 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.
/// </summary>
[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

View File

@ -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)
/// <summary>
/// Check if type code represents a reference (string or object).