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).