Markerless serialization for value types (UseMetadata=false)

Introduced markerless serialization/deserialization for non-nullable value type properties when UseMetadata is false, eliminating type marker bytes for int, long, double, etc. Added ExpectedTypeCode to property accessors/setters to enable this optimization. Refactored property loops in serializer/deserializer for performance and clarity. Default UseMetadata is now false. Improves speed and reduces stream size for common value types while maintaining compatibility for complex types.
This commit is contained in:
Loretta 2026-02-09 08:38:47 +01:00
parent b38fd480d8
commit 97b7813633
5 changed files with 323 additions and 105 deletions

View File

@ -77,17 +77,57 @@ public static partial class AcBinaryDeserializer
// Non-UseMetadata: properties.Length a target property-k száma (source == target) // Non-UseMetadata: properties.Length a target property-k száma (source == target)
var propCount = cacheMap?.Length ?? properties.Length; var propCount = cacheMap?.Length ?? properties.Length;
if (!context.HasMetadata)
{
// Markerless loop: properties with ExpectedTypeCode read raw values directly.
// Properties without ExpectedTypeCode (bool, enum, string, object) use standard marker path.
for (int i = 0; i < propCount; i++)
{
var propInfo = properties[i];
if (propInfo.ExpectedTypeCode.HasValue)
{
ReadAndSetMarkerlessValue(ref context, target, propInfo);
continue;
}
// Non-markerless properties: standard marker-based read
PopulatePropertyWithMarker(ref context, target, propInfo, metadata, nextDepth, isMergeMode, skipDefaultWrite, i, depth);
}
}
else
{
// UseMetadata=true loop — UNCHANGED, zero extra overhead
for (int i = 0; i < propCount; i++) for (int i = 0; i < propCount; i++)
{ {
var propInfo = cacheMap != null ? cacheMap[i] : properties[i]; var propInfo = cacheMap != null ? cacheMap[i] : properties[i];
PopulatePropertyWithMarker(ref context, target, propInfo, metadata, nextDepth, isMergeMode, skipDefaultWrite, i, depth);
}
}
}
/// <summary>
/// Standard marker-based property read. Extracted to avoid duplicating logic in both loops.
/// </summary>
private static void PopulatePropertyWithMarker(
ref BinaryDeserializationContext context,
object target,
BinaryPropertySetterBase? propInfo,
BinaryDeserializeTypeMetadata metadata,
int nextDepth,
bool isMergeMode,
bool skipDefaultWrite,
int propertyIndex,
int depth)
{
var peekCode = context.PeekByte(); var peekCode = context.PeekByte();
// Nincs megfelelő target property → skip // Nincs megfelelő target property → skip
if (propInfo == null) if (propInfo == null)
{ {
SkipValue(ref context, metadata); SkipValue(ref context, metadata);
continue; return;
} }
// Skip marker - property has default/null value // Skip marker - property has default/null value
@ -101,7 +141,7 @@ public static partial class AcBinaryDeserializer
{ {
SetPropertyToDefault(target, propInfo); SetPropertyToDefault(target, propInfo);
} }
continue; return;
} }
// Null values - always set // Null values - always set
@ -109,7 +149,7 @@ public static partial class AcBinaryDeserializer
{ {
context.ReadByte(); // consume Null marker context.ReadByte(); // consume Null marker
propInfo.SetValue(target, null); propInfo.SetValue(target, null);
continue; return;
} }
// Handle collections // Handle collections
@ -130,7 +170,7 @@ public static partial class AcBinaryDeserializer
// Normal populate: replace collection contents // Normal populate: replace collection contents
PopulateListOptimized(ref context, existingList, propInfo, nextDepth); PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
} }
continue; return;
} }
} }
@ -147,7 +187,7 @@ public static partial class AcBinaryDeserializer
var nestedMeta = context.ContextClass.GetWrapper(propInfo.PropertyType).Metadata; var nestedMeta = context.ContextClass.GetWrapper(propInfo.PropertyType).Metadata;
CopyProperties(nestedValue, existingObj, nestedMeta); CopyProperties(nestedValue, existingObj, nestedMeta);
} }
continue; return;
} }
} }
@ -158,7 +198,7 @@ public static partial class AcBinaryDeserializer
// Use typed setters for primitives and strings to avoid ReadValue dispatch // Use typed setters for primitives and strings to avoid ReadValue dispatch
if (propInfo.AccessorType != PropertyAccessorType.Object && if (propInfo.AccessorType != PropertyAccessorType.Object &&
TryReadAndSetTypedValue(ref context, target, propInfo, peekCode)) TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
continue; return;
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth); var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value); propInfo.SetValue(target, value);
@ -167,7 +207,7 @@ public static partial class AcBinaryDeserializer
{ {
var targetType = target.GetType(); var targetType = target.GetType();
throw new AcBinaryDeserializationException( throw new AcBinaryDeserializationException(
$"Type mismatch for property '{propInfo.Name}' (index {i}) on '{targetType.Name}'. " + $"Type mismatch for property '{propInfo.Name}' (index {propertyIndex}) on '{targetType.Name}'. " +
$"Expected type: '{propInfo.PropertyType.FullName}'. " + $"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " + $"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " + $"Position before read: {positionBeforeRead}, current: {context.Position}. " +
@ -180,6 +220,54 @@ public static partial class AcBinaryDeserializer
ex); ex);
} }
} }
/// <summary>
/// Reads a raw value without type marker from stream (markerless mode, UseMetadata=false).
/// The property's type is known from metadata — no type code in the stream.
/// Only called for non-nullable value types with ExpectedTypeCode set.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ReadAndSetMarkerlessValue(ref BinaryDeserializationContext context, object target, BinaryPropertySetterBase propInfo)
{
switch (propInfo.AccessorType)
{
case PropertyAccessorType.Int32:
propInfo.SetInt32(target, context.ReadVarInt());
return;
case PropertyAccessorType.Int64:
propInfo.SetInt64(target, context.ReadVarLong());
return;
case PropertyAccessorType.Double:
propInfo.SetDouble(target, context.ReadDoubleUnsafe());
return;
case PropertyAccessorType.Single:
propInfo.SetSingle(target, context.ReadSingleUnsafe());
return;
case PropertyAccessorType.Decimal:
propInfo.SetDecimal(target, context.ReadDecimalUnsafe());
return;
case PropertyAccessorType.DateTime:
propInfo.SetDateTime(target, context.ReadDateTimeUnsafe());
return;
case PropertyAccessorType.Guid:
propInfo.SetGuid(target, context.ReadGuidUnsafe());
return;
case PropertyAccessorType.Byte:
propInfo.SetByte(target, context.ReadByte());
return;
case PropertyAccessorType.Int16:
propInfo.SetInt16(target, context.ReadInt16Unsafe());
return;
case PropertyAccessorType.UInt16:
propInfo.SetUInt16(target, context.ReadUInt16Unsafe());
return;
case PropertyAccessorType.UInt32:
propInfo.SetUInt32(target, context.ReadVarUInt());
return;
case PropertyAccessorType.UInt64:
propInfo.SetUInt64(target, context.ReadVarULong());
return;
}
} }
/// <summary> /// <summary>

View File

@ -944,29 +944,48 @@ public static partial class AcBinarySerializer
var nextDepth = depth + 1; var nextDepth = depth + 1;
var properties = metadata.Properties; var properties = metadata.Properties;
var propCount = properties.Length; var propCount = properties.Length;
// 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
var hasPropertyFilter = context.HasPropertyFilter; var hasPropertyFilter = context.HasPropertyFilter;
if (!context.UseMetadata)
{
// Markerless loop: no extra branching per property for the common case.
// Properties with ExpectedTypeCode write raw values (no type marker, no skip).
// Properties without ExpectedTypeCode (bool, enum, string, object) use the standard path.
for (var i = 0; i < propCount; i++)
{
var prop = properties[i];
if (prop.ExpectedTypeCode.HasValue)
{
WritePropertyMarkerless(value, prop, context);
}
else if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
{
context.WriteByte(BinaryTypeCode.PropertySkip);
}
else
{
WritePropertyOrSkip(value, prop, context, nextDepth);
}
}
}
else
{
// UseMetadata=true loop — UNCHANGED, zero extra overhead
for (var i = 0; i < propCount; i++) for (var i = 0; i < propCount; i++)
{ {
var prop = properties[i]; var prop = properties[i];
// Skip if filter says no - write skip marker
if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop)) if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
{ {
context.WriteByte(BinaryTypeCode.PropertySkip); context.WriteByte(BinaryTypeCode.PropertySkip);
continue; continue;
} }
// Write property value OR skip marker (single operation, single getter call)
WritePropertyOrSkip(value, prop, context, nextDepth); WritePropertyOrSkip(value, prop, context, nextDepth);
} }
} }
}
/// <summary> /// <summary>
/// Checks if a property value is null or default without boxing for value types. /// Checks if a property value is null or default without boxing for value types.
@ -1283,6 +1302,55 @@ public static partial class AcBinarySerializer
} }
} }
/// <summary>
/// Writes a property value without type marker byte (markerless mode, UseMetadata=false).
/// All values are written including defaults — no PropertySkip markers.
/// Only called for non-nullable value types with ExpectedTypeCode set.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WritePropertyMarkerless(object obj, BinaryPropertyAccessor prop, BinarySerializationContext context)
{
switch (prop.AccessorType)
{
case PropertyAccessorType.Int32:
context.WriteVarInt(prop.GetInt32(obj));
return;
case PropertyAccessorType.Int64:
context.WriteVarLong(prop.GetInt64(obj));
return;
case PropertyAccessorType.Double:
context.WriteRaw(prop.GetDouble(obj));
return;
case PropertyAccessorType.Single:
context.WriteRaw(prop.GetSingle(obj));
return;
case PropertyAccessorType.Decimal:
context.WriteDecimalBits(prop.GetDecimal(obj));
return;
case PropertyAccessorType.DateTime:
context.WriteDateTimeBits(prop.GetDateTime(obj));
return;
case PropertyAccessorType.Guid:
context.WriteGuidBits(prop.GetGuid(obj));
return;
case PropertyAccessorType.Byte:
context.WriteByte(prop.GetByte(obj));
return;
case PropertyAccessorType.Int16:
context.WriteRaw(prop.GetInt16(obj));
return;
case PropertyAccessorType.UInt16:
context.WriteRaw(prop.GetUInt16(obj));
return;
case PropertyAccessorType.UInt32:
context.WriteVarUInt(prop.GetUInt32(obj));
return;
case PropertyAccessorType.UInt64:
context.WriteVarULong(prop.GetUInt64(obj));
return;
}
}
#endregion #endregion
#region Specialized Array Writers #region Specialized Array Writers

View File

@ -82,7 +82,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// allowing the deserializer to match properties by name between different types. /// allowing the deserializer to match properties by name between different types.
/// Default: false (no overhead) /// Default: false (no overhead)
/// </summary> /// </summary>
public bool UseMetadata { get; init; } = true; public bool UseMetadata { get; init; } = false;
/// <summary> /// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode). /// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).

View File

@ -31,6 +31,15 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
/// </summary> /// </summary>
public Func<object, object?> DynamicGetter => _dynamicGetter; public Func<object, object?> DynamicGetter => _dynamicGetter;
/// <summary>
/// Pre-computed expected type code for markerless serialization (UseMetadata=false).
/// When set, the serializer skips writing the type marker byte and the deserializer
/// uses this value instead of reading from the stream.
/// null = marker must be written/read (bool, enum, string, object, collection, nullable value types).
/// Non-null = markerless (non-nullable value types: int, long, double, float, decimal, Guid, DateTime, byte, short, ushort, uint, ulong).
/// </summary>
public byte? ExpectedTypeCode { get; }
protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType) protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
{ {
@ -39,5 +48,28 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
// Cache string intern attribute (inherit: true to check base class properties) // Cache string intern attribute (inherit: true to check base class properties)
var attr = prop.GetCustomAttribute<AcStringInternAttribute>(inherit: true); var attr = prop.GetCustomAttribute<AcStringInternAttribute>(inherit: true);
IsStringInternProperty = attr?.Enabled; IsStringInternProperty = attr?.Enabled;
ExpectedTypeCode = ComputeExpectedTypeCode(AccessorType);
} }
/// <summary>
/// Maps AccessorType to the BinaryTypeCode that would normally be written as marker.
/// Returns null for types that always need a stream marker (bool, enum, string, object/nullable).
/// </summary>
private static byte? ComputeExpectedTypeCode(PropertyAccessorType accessorType) => accessorType switch
{
PropertyAccessorType.Int32 => BinaryTypeCode.Int32,
PropertyAccessorType.Int64 => BinaryTypeCode.Int64,
PropertyAccessorType.Double => BinaryTypeCode.Float64,
PropertyAccessorType.Single => BinaryTypeCode.Float32,
PropertyAccessorType.Decimal => BinaryTypeCode.Decimal,
PropertyAccessorType.DateTime => BinaryTypeCode.DateTime,
PropertyAccessorType.Guid => BinaryTypeCode.Guid,
PropertyAccessorType.Byte => BinaryTypeCode.UInt8,
PropertyAccessorType.Int16 => BinaryTypeCode.Int16,
PropertyAccessorType.UInt16 => BinaryTypeCode.UInt16,
PropertyAccessorType.UInt32 => BinaryTypeCode.UInt32,
PropertyAccessorType.UInt64 => BinaryTypeCode.UInt64,
_ => null // Bool, Enum, String, Object — always read marker from stream
};
} }

View File

@ -23,11 +23,20 @@ public abstract class BinaryPropertySetterBase : PropertySetterBase
/// </summary> /// </summary>
public bool IsCollection { get; } public bool IsCollection { get; }
/// <summary>
/// Pre-computed expected type code for markerless deserialization (UseMetadata=false).
/// When set, the deserializer uses this value instead of reading a marker from the stream.
/// null = marker must be read from stream (bool, enum, string, object, collection, nullable value types).
/// Non-null = markerless (non-nullable value types).
/// </summary>
public byte? ExpectedTypeCode { get; }
protected BinaryPropertySetterBase(PropertyInfo prop, Type declaringType) protected BinaryPropertySetterBase(PropertyInfo prop, Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
{ {
IsCollection = IsCollectionTypeCheck(PropertyType); IsCollection = IsCollectionTypeCheck(PropertyType);
IsComplexType = IsComplex(PropertyType); IsComplexType = IsComplex(PropertyType);
ExpectedTypeCode = ComputeExpectedTypeCode(AccessorType);
} }
public override void SetValue(object target, object? value) public override void SetValue(object target, object? value)
@ -83,4 +92,25 @@ public abstract class BinaryPropertySetterBase : PropertySetterBase
if (ReferenceEquals(actualType, DateTimeOffsetType)) return false; if (ReferenceEquals(actualType, DateTimeOffsetType)) return false;
return true; return true;
} }
/// <summary>
/// Maps AccessorType to the BinaryTypeCode that would normally be read as marker.
/// Returns null for types that always need a stream marker (bool, enum, string, object/nullable).
/// </summary>
private static byte? ComputeExpectedTypeCode(PropertyAccessorType accessorType) => accessorType switch
{
PropertyAccessorType.Int32 => BinaryTypeCode.Int32,
PropertyAccessorType.Int64 => BinaryTypeCode.Int64,
PropertyAccessorType.Double => BinaryTypeCode.Float64,
PropertyAccessorType.Single => BinaryTypeCode.Float32,
PropertyAccessorType.Decimal => BinaryTypeCode.Decimal,
PropertyAccessorType.DateTime => BinaryTypeCode.DateTime,
PropertyAccessorType.Guid => BinaryTypeCode.Guid,
PropertyAccessorType.Byte => BinaryTypeCode.UInt8,
PropertyAccessorType.Int16 => BinaryTypeCode.Int16,
PropertyAccessorType.UInt16 => BinaryTypeCode.UInt16,
PropertyAccessorType.UInt32 => BinaryTypeCode.UInt32,
PropertyAccessorType.UInt64 => BinaryTypeCode.UInt64,
_ => null // Bool, Enum, String, Object — always read marker from stream
};
} }