Add JIT disassembly benchmark and improve string interning

Introduce JitDisassemblyBenchmark for analyzing JIT-generated x64 assembly of AcBinarySerializer hot paths, accessible via --jitasm. Refactor string interning logic to support per-property and string collection interning, adding IsStringCollectionProperty and ScanStringCollection. Update ScanPass and WriteString for finer-grained control. Remove DEBUG-only CurrentPropertyPath in favor of a more robust property tracking approach. Update usage instructions and clean up related code.
This commit is contained in:
Loretta 2026-02-14 19:53:23 +01:00
parent bfab7c16b9
commit 896f720109
7 changed files with 169 additions and 36 deletions

View File

@ -0,0 +1,69 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// JIT disassembly benchmark for AcBinarySerializer hot path analysis.
/// Shows actual x64 assembly generated by the JIT to verify inlining decisions.
///
/// Usage: dotnet run -c Release -- --filter *JitDisassemblyBenchmark*
/// Or from Program.cs: --jitasm
///
/// Output: BenchmarkDotNet artifacts folder contains .asm files with full disassembly.
/// Look for:
/// - WritePropertyOrSkip / WritePropertyMarkerless: are they inlined or called?
/// - WriteInt32 / WriteFloat64Unsafe / etc.: inlined into the caller or separate calls?
/// - context parameter passing: register usage (RCX/RDX/R8/R9)
/// </summary>
[SimpleJob(RuntimeMoniker.Net90)]
[DisassemblyDiagnoser(maxDepth: 4, printSource: true, exportGithubMarkdown: true)]
[MemoryDiagnoser(displayGenColumns: false)]
public class JitDisassemblyBenchmark
{
private TestOrder _order = null!;
private AcBinarySerializerOptions _fastModeOptions = null!;
private byte[] _serialized = null!;
[GlobalSetup]
public void Setup()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
// Medium data: enough properties to show loop behavior, not too large for disassembly
_order = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser);
_fastModeOptions = AcBinarySerializerOptions.FastMode;
_serialized = AcBinarySerializer.Serialize(_order, _fastModeOptions);
}
/// <summary>
/// FastMode serialize — the primary hot path.
/// Disassembly will show WriteObject → WritePropertyMarkerless/WritePropertyOrSkip call chain.
/// </summary>
[Benchmark(Baseline = true)]
public byte[] Serialize_FastMode()
{
return AcBinarySerializer.Serialize(_order, _fastModeOptions);
}
/// <summary>
/// FastMode deserialize — for comparison.
/// </summary>
[Benchmark]
public TestOrder Deserialize_FastMode()
{
return AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _fastModeOptions);
}
}

View File

@ -124,6 +124,12 @@ namespace AyCode.Benchmark
return;
}
if (args.Length > 0 && args[0] == "--jitasm")
{
RunBenchmark<JitDisassemblyBenchmark>(config, benchmarkDir, memDiagDir, "JitDisassemblyBenchmark");
return;
}
Console.WriteLine("Usage:");
Console.WriteLine(" --quick Quick benchmark with tabular output (AcBinary vs MessagePack)");
Console.WriteLine(" --test Quick AcBinary test");
@ -133,6 +139,7 @@ namespace AyCode.Benchmark
Console.WriteLine(" --complex Complex hierarchy (AcBinary vs JSON)");
Console.WriteLine(" --msgpack MessagePack comparison");
Console.WriteLine(" --sizes Size comparison only");
Console.WriteLine(" --jitasm JIT disassembly analysis (shows actual x64 assembly for hot path)");
Console.WriteLine(" --save-coverage <file> Save coverage file into Test_Benchmark_Results/CoverageReport");
if (args.Length == 0)

View File

@ -212,15 +212,15 @@ public static class Program
{
// AcBinary variants
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
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.Default, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
//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
new AcJsonBenchmark(testData.Order, AcJsonSerializerOptions.Default, SerializerAcJsonDefault),

View File

@ -104,13 +104,14 @@ public static partial class AcBinarySerializer
private int[]? _propertyIndexBuffer;
private byte[]? _propertyStateBuffer;
#if DEBUG
/// <summary>
/// DEBUG ONLY: Current property path being serialized (e.g., "Order.Status").
/// Used for string interning analysis.
/// Current property being serialized. Set in WriteObject property loops.
/// Used by WriteString for per-property interning control (UseStringPropertyInterning).
/// null when writing non-property values (arrays, dictionaries, root value).
/// </summary>
internal string? CurrentPropertyPath;
//internal BinaryPropertyAccessorBase? CurrentProperty;
#if DEBUG
/// <summary>
/// DEBUG ONLY: Callback invoked when a string is registered for interning.
/// Parameters: (propertyPath, stringValue)
@ -120,7 +121,7 @@ public static partial class AcBinarySerializer
#endif
// These properties delegate to Options for convenience
public bool UseStringInterning => Options.UseStringInterning != StringInterningMode.None;
internal bool UseStringInterning => Options.UseStringInterning != StringInterningMode.None;
public bool IsValidForInterningString(int strLength)
{

View File

@ -23,6 +23,8 @@ public static partial class AcBinarySerializer
var wrapper = context.GetWrapper(type);
ScanValue(value, wrapper, context, 0);
//context.CurrentProperty = null;
}
private static void ScanValue<TOutput>(object? value, TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, BinarySerializationContext<TOutput> context, int depth)
@ -42,13 +44,23 @@ public static partial class AcBinarySerializer
{
var isStringCollectionElementType = false;
// Per-property interning: use CurrentProperty (set in property loop) when available,
// fallback to global UseStringInterning for root-level collections.
//TODO: A collection esetén is vizsgálni kéne hogy UseStringPropertyInterning - J.
if (metadata.ElementNeedsScan &&
!((isStringCollectionElementType = ReferenceEquals(metadata.CollectionElementType, StringType)) && !context.UseStringInterning))
{
var nextDepth = depth + 1;
if (isStringCollectionElementType)
{
ScanStringCollection(value, context);
return;
}
// 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!);
var elementWrapper = context.GetWrapper(metadata.CollectionElementType!);
if (value is IList list)
{
@ -111,9 +123,12 @@ public static partial class AcBinarySerializer
var refProperties = metadata.ReferenceProperties;
var hasPropertyFilter = context.HasPropertyFilter;
var nextDepth2 = depth + 1;
for (var i = 0; i < refProperties.Length; i++)
{
var prop = refProperties[i];
//context.CurrentProperty = prop;
// 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))
@ -128,6 +143,15 @@ public static partial class AcBinarySerializer
if (str2 != null && context.IsValidForInterningString(str2.Length))
context.ScanInternString(str2);
}
else if (prop.IsStringCollectionProperty)
{
// String collection: per-property interning control, no GetWrapper needed
if (!prop.UseStringPropertyInterning(context.Options.UseStringInterning)) continue;
var propValue = prop.GetValue(value);
if (propValue != null)
ScanStringCollection(propValue, context);
}
else
{
// Object property: use pre-cached wrapper, fallback to GetWrapper on miss/polymorphism
@ -147,6 +171,30 @@ public static partial class AcBinarySerializer
}
}
/// <summary>
/// Scans string elements of a collection for interning. Uses IList fast path when available.
/// </summary>
private static void ScanStringCollection<TOutput>(object collection, BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
if (collection is IList list)
{
for (var i = 0; i < list.Count; i++)
{
if (list[i] is string str && context.IsValidForInterningString(str.Length))
context.ScanInternString(str);
}
}
else if (collection is IEnumerable enumerable)
{
foreach (var item in enumerable)
{
if (item is string str && context.IsValidForInterningString(str.Length))
context.ScanInternString(str);
}
}
}
/// <summary>
/// Scans a collection item. Uses pre-cached element wrapper when runtime type matches,
/// falls back to GetWrapper for polymorphic items.
@ -155,15 +203,9 @@ public static partial class AcBinarySerializer
private static void ScanItem<TOutput>(object item, TypeMetadataWrapper<BinarySerializeTypeMetadata>? elementWrapper, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase
{
// String fast path — avoid GetWrapper entirely
if (item is string str)
{
if (context.UseStringInterning && context.IsValidForInterningString(str.Length))
context.ScanInternString(str);
return;
}
var itemType = item.GetType();
var itemWrapper = itemType == elementWrapper!.Metadata.SourceType ? elementWrapper : context.GetWrapper(itemType);
var itemWrapper = item.GetType() == elementWrapper!.Metadata.SourceType ? elementWrapper : context.GetWrapper(item.GetType());
ScanValue(item, itemWrapper, context, depth);
}
}

View File

@ -840,10 +840,7 @@ public static partial class AcBinarySerializer
return;
}
// String interning: only for strings within length range
// MaxStringInternLength == 0 means no max limit
//TODO: A prop.UseStringPropertyInterning-et kéne használni! - J.
if (context.UseStringInterning && context.IsValidForInterningString(value.Length))
if (context.UseStringInterning && context.IsValidForInterningString(value.Length))// && context.CurrentProperty!.UseStringPropertyInterning(context.Options.UseStringInterning))
{
ref var interEntry = ref context.GetInternedStringEntry(value, out bool found);
@ -867,7 +864,9 @@ public static partial class AcBinarySerializer
}
// CacheIndex < 0 or not found → single occurrence, fall through to FixStr/String path
#if DEBUG
context.OnStringInterned?.Invoke(context.CurrentPropertyPath, value);
context.OnStringInterned?.Invoke(
context.CurrentProperty != null ? $"{context.CurrentProperty.DeclaringType.Name}.{context.CurrentProperty.Name}" : null,
value);
#endif
}
@ -1044,6 +1043,7 @@ public static partial class AcBinarySerializer
for (var i = 0; i < propCount; i++)
{
var prop = properties[i];
//context.CurrentProperty = prop;
if (prop.ExpectedTypeCode.HasValue)
{
@ -1065,6 +1065,7 @@ public static partial class AcBinarySerializer
for (var i = 0; i < propCount; i++)
{
var prop = properties[i];
//context.CurrentProperty = prop;
if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
{
@ -1363,9 +1364,6 @@ public static partial class AcBinarySerializer
}
else
{
#if DEBUG
context.CurrentPropertyPath = $"{prop.DeclaringType.Name}.{prop.Name}";
#endif
WriteString(value, context);
}
return;
@ -1384,9 +1382,6 @@ public static partial class AcBinarySerializer
}
else
{
#if DEBUG
context.CurrentPropertyPath = $"{prop.DeclaringType.Name}.{prop.Name}";
#endif
var runtimeType = value.GetType();
var complexIdx = prop.ComplexPropertyIndex;
if (complexIdx >= 0)
@ -1584,7 +1579,6 @@ public static partial class AcBinarySerializer
{
context.WriteByte(boolArray[i] ? BinaryTypeCode.True : BinaryTypeCode.False);
}
context.WriteVarUInt((uint)boolArray.Length);
return true;
}

View File

@ -24,6 +24,7 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
/// Used to pre-cache TypeMetadataWrapper per property type, eliminating GetWrapper dictionary lookups.
/// </summary>
public int ComplexPropertyIndex { get; internal set; } = -1;
public bool IsStringCollectionProperty { get; }
/// <summary>
/// Cached [AcStringIntern] attribute value for this property.
@ -55,8 +56,10 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType)
: base(prop, declaringType)
{
IsStringCollectionProperty = IsStringCollection(prop.PropertyType);
// All typed getters are initialized in PropertyAccessorBase
if (AccessorType == PropertyAccessorType.String)
if (AccessorType == PropertyAccessorType.String || IsStringCollectionProperty)
{
// Cache [AcStringIntern] attribute (inherit: true to check base class properties)
var internAttr = prop.GetCustomAttribute<AcStringInternAttribute>(inherit: true);
@ -72,6 +75,23 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
ExpectedTypeCode = ComputeExpectedTypeCode(AccessorType);
}
/// <summary>
/// Checks if the property type is a collection with string elements (string[], List&lt;string&gt;, etc.).
/// </summary>
private static bool IsStringCollection(Type propertyType)
{
if (propertyType.IsArray)
return propertyType.GetElementType() == typeof(string);
if (propertyType.IsGenericType)
{
var args = propertyType.GetGenericArguments();
return args.Length == 1 && args[0] == typeof(string);
}
return false;
}
/// <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).