diff --git a/AyCode.Benchmark/JitDisassemblyBenchmark.cs b/AyCode.Benchmark/JitDisassemblyBenchmark.cs
new file mode 100644
index 0000000..69d7828
--- /dev/null
+++ b/AyCode.Benchmark/JitDisassemblyBenchmark.cs
@@ -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;
+
+///
+/// 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)
+///
+[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);
+ }
+
+ ///
+ /// FastMode serialize — the primary hot path.
+ /// Disassembly will show WriteObject → WritePropertyMarkerless/WritePropertyOrSkip call chain.
+ ///
+ [Benchmark(Baseline = true)]
+ public byte[] Serialize_FastMode()
+ {
+ return AcBinarySerializer.Serialize(_order, _fastModeOptions);
+ }
+
+ ///
+ /// FastMode deserialize — for comparison.
+ ///
+ [Benchmark]
+ public TestOrder Deserialize_FastMode()
+ {
+ return AcBinaryDeserializer.Deserialize(_serialized, _fastModeOptions);
+ }
+}
diff --git a/AyCode.Benchmark/Program.cs b/AyCode.Benchmark/Program.cs
index c46acb9..648da8c 100644
--- a/AyCode.Benchmark/Program.cs
+++ b/AyCode.Benchmark/Program.cs
@@ -124,6 +124,12 @@ namespace AyCode.Benchmark
return;
}
+ if (args.Length > 0 && args[0] == "--jitasm")
+ {
+ RunBenchmark(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 Save coverage file into Test_Benchmark_Results/CoverageReport");
if (args.Length == 0)
diff --git a/AyCode.Core.Serializers.Console/Program.cs b/AyCode.Core.Serializers.Console/Program.cs
index 77ef1df..04309a4 100644
--- a/AyCode.Core.Serializers.Console/Program.cs
+++ b/AyCode.Core.Serializers.Console/Program.cs
@@ -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),
diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs
index e4e4b41..f1f0a27 100644
--- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs
+++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs
@@ -104,13 +104,14 @@ public static partial class AcBinarySerializer
private int[]? _propertyIndexBuffer;
private byte[]? _propertyStateBuffer;
-#if DEBUG
///
- /// 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).
///
- internal string? CurrentPropertyPath;
+ //internal BinaryPropertyAccessorBase? CurrentProperty;
+#if DEBUG
///
/// 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)
{
diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs
index bb95bcc..bda59f9 100644
--- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs
+++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs
@@ -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(object? value, TypeMetadataWrapper wrapper, BinarySerializationContext 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
}
}
+ ///
+ /// Scans string elements of a collection for interning. Uses IList fast path when available.
+ ///
+ private static void ScanStringCollection(object collection, BinarySerializationContext 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);
+ }
+ }
+ }
+
///
/// 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(object item, TypeMetadataWrapper? elementWrapper, BinarySerializationContext 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);
}
}
diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs
index 8f81ca7..5046cd1 100644
--- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs
+++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs
@@ -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;
}
diff --git a/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs b/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs
index f29e66a..ab4be0b 100644
--- a/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs
+++ b/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs
@@ -24,6 +24,7 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
/// Used to pre-cache TypeMetadataWrapper per property type, eliminating GetWrapper dictionary lookups.
///
public int ComplexPropertyIndex { get; internal set; } = -1;
+ public bool IsStringCollectionProperty { get; }
///
/// Cached [AcStringIntern] attribute value for this property.
@@ -55,12 +56,14 @@ 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(inherit: true);
-
+
var stringInternAttributeValue = internAttr?.Enabled;
byte flags = 0;
@@ -72,6 +75,23 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
ExpectedTypeCode = ComputeExpectedTypeCode(AccessorType);
}
+ ///
+ /// Checks if the property type is a collection with string elements (string[], List<string>, etc.).
+ ///
+ 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;
+ }
+
///
/// 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).