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

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

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

View File

@ -23,6 +23,8 @@ public static partial class AcBinarySerializer
var wrapper = context.GetWrapper(type); var wrapper = context.GetWrapper(type);
ScanValue(value, wrapper, context, 0); ScanValue(value, wrapper, context, 0);
//context.CurrentProperty = null;
} }
private static void ScanValue<TOutput>(object? value, TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, BinarySerializationContext<TOutput> context, int depth) 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; 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. //TODO: A collection esetén is vizsgálni kéne hogy UseStringPropertyInterning - J.
if (metadata.ElementNeedsScan && if (metadata.ElementNeedsScan &&
!((isStringCollectionElementType = ReferenceEquals(metadata.CollectionElementType, StringType)) && !context.UseStringInterning)) !((isStringCollectionElementType = ReferenceEquals(metadata.CollectionElementType, StringType)) && !context.UseStringInterning))
{ {
var nextDepth = depth + 1; 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) // 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) if (value is IList list)
{ {
@ -111,9 +123,12 @@ public static partial class AcBinarySerializer
var refProperties = metadata.ReferenceProperties; var refProperties = metadata.ReferenceProperties;
var hasPropertyFilter = context.HasPropertyFilter; 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];
//context.CurrentProperty = prop;
// Must match write pass: filtered properties write PropertySkip (no value) → // Must match write pass: filtered properties write PropertySkip (no value) →
// scanning them would assign CacheIndex for strings/objects never in output // scanning them would assign CacheIndex for strings/objects never in output
if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop)) if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
@ -128,6 +143,15 @@ public static partial class AcBinarySerializer
if (str2 != null && context.IsValidForInterningString(str2.Length)) if (str2 != null && context.IsValidForInterningString(str2.Length))
context.ScanInternString(str2); 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 else
{ {
// Object property: use pre-cached wrapper, fallback to GetWrapper on miss/polymorphism // 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> /// <summary>
/// Scans a collection item. Uses pre-cached element wrapper when runtime type matches, /// Scans a collection item. Uses pre-cached element wrapper when runtime type matches,
/// falls back to GetWrapper for polymorphic items. /// 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) 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 var itemType = item.GetType();
if (item is string str) var itemWrapper = itemType == elementWrapper!.Metadata.SourceType ? elementWrapper : context.GetWrapper(itemType);
{
if (context.UseStringInterning && context.IsValidForInterningString(str.Length))
context.ScanInternString(str);
return;
}
var itemWrapper = item.GetType() == elementWrapper!.Metadata.SourceType ? elementWrapper : context.GetWrapper(item.GetType());
ScanValue(item, itemWrapper, context, depth); ScanValue(item, itemWrapper, context, depth);
} }
} }

View File

@ -840,10 +840,7 @@ public static partial class AcBinarySerializer
return; return;
} }
// String interning: only for strings within length range if (context.UseStringInterning && context.IsValidForInterningString(value.Length))// && context.CurrentProperty!.UseStringPropertyInterning(context.Options.UseStringInterning))
// MaxStringInternLength == 0 means no max limit
//TODO: A prop.UseStringPropertyInterning-et kéne használni! - J.
if (context.UseStringInterning && context.IsValidForInterningString(value.Length))
{ {
ref var interEntry = ref context.GetInternedStringEntry(value, out bool found); 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 // CacheIndex < 0 or not found → single occurrence, fall through to FixStr/String path
#if DEBUG #if DEBUG
context.OnStringInterned?.Invoke(context.CurrentPropertyPath, value); context.OnStringInterned?.Invoke(
context.CurrentProperty != null ? $"{context.CurrentProperty.DeclaringType.Name}.{context.CurrentProperty.Name}" : null,
value);
#endif #endif
} }
@ -1044,6 +1043,7 @@ public static partial class AcBinarySerializer
for (var i = 0; i < propCount; i++) for (var i = 0; i < propCount; i++)
{ {
var prop = properties[i]; var prop = properties[i];
//context.CurrentProperty = prop;
if (prop.ExpectedTypeCode.HasValue) if (prop.ExpectedTypeCode.HasValue)
{ {
@ -1065,6 +1065,7 @@ public static partial class AcBinarySerializer
for (var i = 0; i < propCount; i++) for (var i = 0; i < propCount; i++)
{ {
var prop = properties[i]; var prop = properties[i];
//context.CurrentProperty = prop;
if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop)) if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
{ {
@ -1363,9 +1364,6 @@ public static partial class AcBinarySerializer
} }
else else
{ {
#if DEBUG
context.CurrentPropertyPath = $"{prop.DeclaringType.Name}.{prop.Name}";
#endif
WriteString(value, context); WriteString(value, context);
} }
return; return;
@ -1384,9 +1382,6 @@ public static partial class AcBinarySerializer
} }
else else
{ {
#if DEBUG
context.CurrentPropertyPath = $"{prop.DeclaringType.Name}.{prop.Name}";
#endif
var runtimeType = value.GetType(); var runtimeType = value.GetType();
var complexIdx = prop.ComplexPropertyIndex; var complexIdx = prop.ComplexPropertyIndex;
if (complexIdx >= 0) if (complexIdx >= 0)
@ -1584,7 +1579,6 @@ public static partial class AcBinarySerializer
{ {
context.WriteByte(boolArray[i] ? BinaryTypeCode.True : BinaryTypeCode.False); context.WriteByte(boolArray[i] ? BinaryTypeCode.True : BinaryTypeCode.False);
} }
context.WriteVarUInt((uint)boolArray.Length);
return true; 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. /// Used to pre-cache TypeMetadataWrapper per property type, eliminating GetWrapper dictionary lookups.
/// </summary> /// </summary>
public int ComplexPropertyIndex { get; internal set; } = -1; public int ComplexPropertyIndex { get; internal set; } = -1;
public bool IsStringCollectionProperty { get; }
/// <summary> /// <summary>
/// Cached [AcStringIntern] attribute value for this property. /// Cached [AcStringIntern] attribute value for this property.
@ -55,12 +56,14 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType) protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
{ {
IsStringCollectionProperty = IsStringCollection(prop.PropertyType);
// All typed getters are initialized in PropertyAccessorBase // 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) // Cache [AcStringIntern] attribute (inherit: true to check base class properties)
var internAttr = prop.GetCustomAttribute<AcStringInternAttribute>(inherit: true); var internAttr = prop.GetCustomAttribute<AcStringInternAttribute>(inherit: true);
var stringInternAttributeValue = internAttr?.Enabled; var stringInternAttributeValue = internAttr?.Enabled;
byte flags = 0; byte flags = 0;
@ -72,6 +75,23 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
ExpectedTypeCode = ComputeExpectedTypeCode(AccessorType); 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> /// <summary>
/// Maps AccessorType to the BinaryTypeCode that would normally be written as marker. /// 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). /// Returns null for types that always need a stream marker (bool, enum, string, object/nullable).