Optimize AcBinary scan, string caching, and benchmarks

- Refactor collection scan to pre-cache element wrappers and optimize ScanItem for polymorphic types
- Add DisableStringCaching to deserializer; call it on first interned string marker
- Update benchmarks to restore default and no-ref variants, clarify string interning options
- Ensure property scanning respects property filters, skipping filtered properties
This commit is contained in:
Loretta 2026-02-13 22:58:07 +01:00
parent f84dcb773d
commit a0a6ac8ef4
4 changed files with 40 additions and 14 deletions

View File

@ -212,15 +212,15 @@ public static class Program
{ {
// AcBinary variants // AcBinary variants
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault), new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef), new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None }, SerializerAcBinaryNoIntern),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode), new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern), new AcBinaryBenchmark(testData.Order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None }, SerializerAcBinaryNoIntern),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
// AcJson // AcJson
new AcJsonBenchmark(testData.Order, AcJsonSerializerOptions.Default, SerializerAcJsonDefault), new AcJsonBenchmark(testData.Order, AcJsonSerializerOptions.Default, SerializerAcJsonDefault),

View File

@ -276,6 +276,16 @@ public static partial class AcBinaryDeserializer
#endregion #endregion
/// <summary>
/// Called on first StringInternFirst marker — disables _stringCache because
/// interned strings are resolved via _internCache and plain strings appear only once.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void DisableStringCaching()
{
_useStringCaching = false;
}
#region Bytes & String Reading #region Bytes & String Reading
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@ -912,6 +912,9 @@ public static partial class AcBinaryDeserializer
private static string ReadAndRegisterInternedString<TInput>(BinaryDeserializationContext<TInput> context) private static string ReadAndRegisterInternedString<TInput>(BinaryDeserializationContext<TInput> context)
where TInput : struct, IBinaryInputBase where TInput : struct, IBinaryInputBase
{ {
// First StringInternFirst marker proves payload uses string interning →
// plain String entries appear only once, so _stringCache would never hit
context.DisableStringCaching();
var cacheIndex = (int)context.ReadVarUInt(); var cacheIndex = (int)context.ReadVarUInt();
var length = (int)context.ReadVarUInt(); var length = (int)context.ReadVarUInt();
if (length == 0) return string.Empty; if (length == 0) return string.Empty;

View File

@ -40,16 +40,22 @@ public static partial class AcBinarySerializer
// Collection → iterate elements using IList fast path (no IEnumerator alloc) // Collection → iterate elements using IList fast path (no IEnumerator alloc)
if (metadata.IsCollection) if (metadata.IsCollection)
{ {
if (metadata.ElementNeedsScan) var isStringCollectionElementType = false;
if (metadata.ElementNeedsScan &&
!((isStringCollectionElementType = ReferenceEquals(metadata.CollectionElementType, StringType)) && !context.UseStringInterning))
{ {
var nextDepth = depth + 1; var nextDepth = depth + 1;
// Pre-cache element wrapper for non-string collections (string fast path in ScanItem doesn't need it)
var elementWrapper = isStringCollectionElementType ? null : context.GetWrapper(metadata.CollectionElementType!);
if (value is IList list) if (value is IList list)
{ {
for (var i = 0; i < list.Count; i++) for (var i = 0; i < list.Count; i++)
{ {
var item = list[i]; var item = list[i];
if (item != null) if (item != null)
ScanItem(item, context, nextDepth); ScanItem(item, elementWrapper, context, nextDepth);
} }
} }
else if (value is IEnumerable enumerable) else if (value is IEnumerable enumerable)
@ -57,7 +63,7 @@ public static partial class AcBinarySerializer
foreach (var item in enumerable) foreach (var item in enumerable)
{ {
if (item != null) if (item != null)
ScanItem(item, context, nextDepth); ScanItem(item, elementWrapper, context, nextDepth);
} }
} }
} }
@ -102,10 +108,16 @@ public static partial class AcBinarySerializer
// Recursive scan on reference properties only // Recursive scan on reference properties only
// Use typed getter for strings (much faster than reflection GetValue) // Use typed getter for strings (much faster than reflection GetValue)
var refProperties = metadata.ReferenceProperties; var refProperties = metadata.ReferenceProperties;
var hasPropertyFilter = context.HasPropertyFilter;
var nextDepth2 = depth + 1; var nextDepth2 = depth + 1;
for (var i = 0; i < refProperties.Length; i++) for (var i = 0; i < refProperties.Length; i++)
{ {
var prop = refProperties[i]; var prop = refProperties[i];
// Must match write pass: filtered properties write PropertySkip (no value) →
// scanning them would assign CacheIndex for strings/objects never in output
if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
continue;
if (prop.AccessorType == PropertyAccessorType.String) if (prop.AccessorType == PropertyAccessorType.String)
{ {
// Fast path: typed getter for string // Fast path: typed getter for string
@ -127,10 +139,11 @@ public static partial class AcBinarySerializer
} }
/// <summary> /// <summary>
/// Scans a collection item. Handles string fast path and gets wrapper for the runtime type. /// Scans a collection item. Uses pre-cached element wrapper when runtime type matches,
/// falls back to GetWrapper for polymorphic items.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ScanItem<TOutput>(object item, BinarySerializationContext<TOutput> context, int depth) private static void ScanItem<TOutput>(object item, TypeMetadataWrapper<BinarySerializeTypeMetadata>? elementWrapper, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase where TOutput : struct, IBinaryOutputBase
{ {
// String fast path — avoid GetWrapper entirely // String fast path — avoid GetWrapper entirely
@ -141,7 +154,7 @@ public static partial class AcBinarySerializer
return; return;
} }
var itemWrapper = context.GetWrapper(item.GetType()); var itemWrapper = item.GetType() == elementWrapper!.Metadata.SourceType ? elementWrapper : context.GetWrapper(item.GetType());
ScanValue(item, itemWrapper, context, depth); ScanValue(item, itemWrapper, context, depth);
} }
} }