diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9a34168..d12a13f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -120,7 +120,9 @@ "Bash(git -C \"H:/Applications/Aycode/Source/AyCode.Core\" diff HEAD -- \"AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs\" \"AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs\")", "Bash(DOTNET_TieredCompilation=0 DOTNET_JitDisasm='*GeneratedWriter*' dotnet run --project AyCode.Benchmark/AyCode.Benchmark.csproj -c Release -- --jitasm)", "Bash(echo \"EXIT=$?\")", - "Bash(awk -F: '$1>8289')" + "Bash(awk -F: '$1>8289')", + "Bash(DOTNET_TieredCompilation=0 dotnet run --project AyCode.Core.Serializers.Console/AyCode.Core.Serializers.Console.csproj -c Release -- FastestByte)", + "Bash(DOTNET_TieredCompilation=0 dotnet run --project AyCode.Core.Serializers.Console/AyCode.Core.Serializers.Console.csproj -c Release -- FastestByte AsciiShort)" ] } } diff --git a/AyCode.Benchmark/Reporting/BenchmarkReportWriter.cs b/AyCode.Benchmark/Reporting/BenchmarkReportWriter.cs index 97c8dd1..ae17afd 100644 --- a/AyCode.Benchmark/Reporting/BenchmarkReportWriter.cs +++ b/AyCode.Benchmark/Reporting/BenchmarkReportWriter.cs @@ -462,6 +462,7 @@ public static class BenchmarkReportWriter sb.AppendLine($"║ Source: {ctx.SourceTag}".PadRight(100) + "║"); sb.AppendLine($"║ Build: {ctx.BuildConfiguration}".PadRight(100) + "║"); sb.AppendLine($"║ Charset: {ctx.CharsetName}".PadRight(100) + "║"); + sb.AppendLine($"║ .NET: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription} ({Environment.Version})".PadRight(100) + "║"); // For BDN-sourced contexts, warmup / samples / target are managed inside BDN's job config (not by // our adaptive engine) — surfacing the placeholder zeros as concrete numbers would be misleading. // Print "BDN-managed" instead; raw BDN config is recoverable from the BDN-native artifacts under .../BDN/. diff --git a/AyCode.Core.Serializers.Console/Program.cs b/AyCode.Core.Serializers.Console/Program.cs index 3e0228b..652237e 100644 --- a/AyCode.Core.Serializers.Console/Program.cs +++ b/AyCode.Core.Serializers.Console/Program.cs @@ -75,14 +75,15 @@ public static class Program } /// - /// Parses CLI arguments into (layer, opMode, serializerMode). Uses - /// case-insensitive against the three enums in this order: (matches - /// "FastestByte"/"AsyncPipe"), (matches "Serialize"/"Deserialize"), then - /// (matches "Core"/"Comprehensive"/"Edge"/"Small"/...). Special-cased: - /// "quick" mutates warmup/iter counts but selects no layer/op/mode. - /// Unknown args silently default to (All, All, Standard) — matches the prior behavior where unrecognized - /// args fell through 's default branch (full unfiltered suite). - /// Returns false only if the caller-side path would need to abort; currently always returns true. + /// Parses CLI arguments into (layer, opMode, serializerMode) and, as a side effect, the active + /// charset (). Each arg is classified + /// independently and case-insensitively, so multiple args combine in any order — e.g. + /// FastestByte AsciiShort or Serialize Large Latin1Short. Per arg, in order: + /// "quick" (mutates warmup/iter counts), , + /// , , then a charset name + /// (see ). Unrecognized args are warned and ignored; dimensions left + /// unset keep their defaults (All, All, Standard, and the + /// field default for charset). Always returns true (kept for caller-side abort symmetry). /// private static bool TryParseCliArgs(string[] args, out BenchmarkLayer layer, out BenchmarkOpMode opMode, out SerializerSelectionMode serializerMode) { @@ -90,43 +91,80 @@ public static class Program opMode = BenchmarkOpMode.All; serializerMode = SerializerSelectionMode.Standard; - var arg = args[0]; - - // Quick mode: short warmup, few iterations, small sample count. Not an enum value — it's a - // Configuration meta-flag, so handle it before the enum-parse cascade. - if (string.Equals(arg, "quick", StringComparison.OrdinalIgnoreCase)) + // Each arg is classified independently → multiple args combine in any order. Without the + // charset branch the CLI path never sets the charset, so it silently used the Latin1Long + // field default — diverging from interactive runs (where the menu pins it). + foreach (var arg in args) { - Configuration.WarmupIterations = 5; - Configuration.TestIterations = 100; - Configuration.BenchmarkSamples = 3; - return true; + // Quick mode: short warmup, few iterations, small sample count. Not an enum value — it's a + // Configuration meta-flag, so handle it before the enum-parse cascade. + if (string.Equals(arg, "quick", StringComparison.OrdinalIgnoreCase)) + { + Configuration.WarmupIterations = 5; + Configuration.TestIterations = 100; + Configuration.BenchmarkSamples = 3; + continue; + } + + // Serializer-selection (AsyncPipe/FastestByte/Standard). + if (Enum.TryParse(arg, ignoreCase: true, out var sm)) + { + serializerMode = sm; + continue; + } + + // Op-mode (Serialize/Deserialize/All). + if (Enum.TryParse(arg, ignoreCase: true, out var om)) + { + opMode = om; + continue; + } + + // Layer (Core/Comprehensive/Edge/Small/Medium/Large/Repeated/Deep/All). + if (Enum.TryParse(arg, ignoreCase: true, out var ly)) + { + layer = ly; + continue; + } + + // Charset (long-string suffix profile) — mirrors the interactive ShowCharsetSettingsMenu. + if (TryApplyCharsetArg(arg)) + continue; + + // Unknown arg — ignored, defaults stand. Matches prior unrecognized-arg leniency. + System.Console.Error.WriteLine($"Warning: unrecognized argument '{arg}'. Ignored (defaults: Layer=All, OpMode=All, SerializerMode=Standard, charset unchanged)."); } - // Serializer-selection first (AsyncPipe/FastestByte/Standard) — narrower set than layers, - // and "AsyncPipe" forced layer=All in the old code anyway. - if (Enum.TryParse(arg, ignoreCase: true, out var sm)) - { - serializerMode = sm; - return true; - } + return true; + } - // Op-mode (Serialize/Deserialize/All). - if (Enum.TryParse(arg, ignoreCase: true, out var om)) + /// + /// Maps a case-insensitive charset name to its value and assigns + /// . Names mirror the interactive + /// ShowCharsetSettingsMenu options. members are const string, + /// so this is a name→value match rather than an . + /// Returns false when the name is not a known charset (the caller then treats the arg as unknown). + /// + private static bool TryApplyCharsetArg(string arg) + { + string? suffix = arg.ToLowerInvariant() switch { - opMode = om; - return true; - } - - // Layer (Core/Comprehensive/Edge/Small/Medium/Large/Repeated/Deep/All). - if (Enum.TryParse(arg, ignoreCase: true, out var ly)) - { - layer = ly; - return true; - } - - // Unknown arg — defaults remain (All, All, Standard). Matches prior behaviour where the - // unrecognized string fell through FilterByLayer's `_ => all.ToList()` default branch. - System.Console.Error.WriteLine($"Warning: unrecognized argument '{arg}'. Running full suite (Layer=All, OpMode=All, SerializerMode=Standard)."); + "latin1fixascii" => CharsetSuffixes.Latin1FixAscii, + "asciishort" => CharsetSuffixes.AsciiShort, + "asciilong" => CharsetSuffixes.AsciiLong, + "latin1short" => CharsetSuffixes.Latin1Short, + "latin1long" => CharsetSuffixes.Latin1Long, + "cjkbmpshort" => CharsetSuffixes.CjkBmpShort, + "cjkbmplong" => CharsetSuffixes.CjkBmpLong, + "cyrillicshort" => CharsetSuffixes.CyrillicShort, + "cyrilliclong" => CharsetSuffixes.CyrillicLong, + "mixedshort" => CharsetSuffixes.MixedShort, + "mixedlong" => CharsetSuffixes.MixedLong, + _ => null + }; + if (suffix is null) + return false; + BenchmarkTestDataProvider.LongStringSuffix = suffix; return true; } diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.GenWriter.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.GenWriter.cs index f44ab04..6ec3706 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.GenWriter.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.GenWriter.cs @@ -257,11 +257,27 @@ public partial class AcBinarySourceGenerator switch (p.TypeKind) { case PropertyTypeKind.String: + // Inlined string-property write -- streamlined chain (bypasses WriteStringGenerated / + // WriteString): FastWire -> markerless UTF-16; else null -> PropertySkip, empty -> + // StringEmpty, content -> interning attempt (eligible props) + WriteStringWithDispatch. + // A local pins the single getter evaluation; the chain is small + branch-friendly so + // the JIT folds it into WriteProperties (no per-string call frame). + sb.AppendLine($"{i}var str_{p.Name} = {a};"); + sb.AppendLine($"{i}if (context.FastWire) context.WriteStringUtf16Markerless(str_{p.Name});"); + sb.AppendLine($"{i}else if (str_{p.Name} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); + sb.AppendLine($"{i}else if (str_{p.Name}.Length == 0) context.WriteByte(BinaryTypeCode.StringEmpty);"); if (p.InterningFlags == 0) - sb.AppendLine($"{i}context.StringInternEligible = false;"); + { + sb.AppendLine($"{i}else context.WriteStringWithDispatch(str_{p.Name});"); + } else - sb.AppendLine($"{i}context.StringInternEligible = ({p.InterningFlags} & context.InternBit) != 0;"); - sb.AppendLine($"{i}AcBinarySerializer.WriteStringGenerated({a}, context);"); + { + sb.AppendLine($"{i}else"); + sb.AppendLine($"{i}{{"); + sb.AppendLine($"{i} context.StringInternEligible = ({p.InterningFlags} & context.InternBit) != 0;"); + sb.AppendLine($"{i} if (!context.TryWriteInternedString(str_{p.Name})) context.WriteStringWithDispatch(str_{p.Name});"); + sb.AppendLine($"{i}}}"); + } break; case PropertyTypeKind.Complex: // Complex object: direct write bypasses GetWrapper + WriteObject pipeline entirely diff --git a/AyCode.Core.Tests/TestModels/TestDataFactory.cs b/AyCode.Core.Tests/TestModels/TestDataFactory.cs index d521026..1e4b0d9 100644 --- a/AyCode.Core.Tests/TestModels/TestDataFactory.cs +++ b/AyCode.Core.Tests/TestModels/TestDataFactory.cs @@ -52,12 +52,12 @@ public abstract class TestDataFactory /// public TOptions Options { get; private set; } = null!; - public byte MaxDepth => Options.MaxDepth; + public byte _maxDepth; - public ReferenceHandlingMode ReferenceHandling => Options.ReferenceHandling; - - /// - /// Pre-computed: ReferenceHandling != None. Avoids Options field chain per call. - /// - private bool _hasRefHandling; + public ReferenceHandlingMode ReferenceHandling; /// /// Pre-computed: ReferenceHandling == OnlyId (not All). When true, only IId types are tracked. /// private bool _hasIdHandling; + /// + /// Pre-computed: ReferenceHandling != None. Avoids Options field chain per call. + /// + internal bool HasRefHandling; /// /// Pre-computed: ReferenceHandling == All. When true, all reference types are tracked. /// - private bool _hasAllRefHandling; + internal bool HasAllRefHandling; - internal bool HasRefHandling => _hasRefHandling; - internal bool HasAllRefHandling => _hasAllRefHandling; public bool ThrowOnCircularReference => Options.ThrowOnCircularReference; /// /// Global shared cache for metadata (thread-safe, shared across all contexts). @@ -88,7 +85,7 @@ public abstract class AcSerializerContextBase [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool UseTypeReferenceHandling(TMetadata metaData) { - return _hasRefHandling && (metaData.IsIId || !_hasIdHandling); + return HasRefHandling && (metaData.IsIId || !_hasIdHandling); } #region Wrapper Access @@ -204,9 +201,14 @@ public abstract class AcSerializerContextBase public virtual void Reset(TOptions options) { Options = options; - _hasRefHandling = options.ReferenceHandling != ReferenceHandlingMode.None; - _hasIdHandling = options.ReferenceHandling == ReferenceHandlingMode.OnlyId; - _hasAllRefHandling = options.ReferenceHandling == ReferenceHandlingMode.All; + + ReferenceHandling = options.ReferenceHandling; + + _hasIdHandling = ReferenceHandling == ReferenceHandlingMode.OnlyId; + HasRefHandling = ReferenceHandling != ReferenceHandlingMode.None; + HasAllRefHandling = ReferenceHandling == ReferenceHandlingMode.All; + + _maxDepth = Options.MaxDepth; } /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs index 977f0b6..fae1274 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs @@ -67,7 +67,7 @@ public static partial class AcBinaryDeserializer public bool FastWire; // Options-derived properties - public byte MinStringInternLength => Options.MinStringInternLength; + public byte MinStringInternLength; /// /// Chain reference tracker for maintaining object identity across chain operations. @@ -164,6 +164,8 @@ public static partial class AcBinaryDeserializer RemoveOrphanedItems = false; FastWire = Options.WireMode == WireMode.Fast; + MinStringInternLength = Options.MinStringInternLength; + ChainTracker = null; } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs index 4022017..df66d8d 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs @@ -390,7 +390,7 @@ public static partial class AcBinarySerializer internal bool TryEnterRecursion(bool hasTruncatePath) { if (!_needsDepthCheck) return false; - if (_recursionDepth >= MaxDepth) + if (_recursionDepth >= _maxDepth) { OnMaxDepthHit(hasTruncatePath); return true; @@ -425,7 +425,7 @@ public static partial class AcBinarySerializer { if (_maxDepthAction == MaxDepthBehavior.Throw) throw new InvalidOperationException( - $"AcBinary: recursion depth exceeded MaxDepth={MaxDepth} (depth={_recursionDepth}, position={Position})"); + $"AcBinary: recursion depth exceeded MaxDepth={_maxDepth} (depth={_recursionDepth}, position={Position})"); // Truncate: write Null in place of the object. No rewind — check fires BEFORE any marker write. if (hasTruncatePath) WriteByte(BinaryTypeCode.Null); } @@ -451,6 +451,31 @@ public static partial class AcBinarySerializer #region Write Methods — inline, zero virtual dispatch + /// + /// Unchecked ref byte into at — omits the + /// JIT array bounds-check that a plain _buffer[position] index emits on every write. + /// + /// + /// Safe by the buffer invariant: every write primitive first guarantees position < _bufferEnd + /// (the _position < _bufferEnd grow-guard or ), and _bufferEnd + /// is "one past the last writable index in _buffer" ⇒ _bufferEnd <= _buffer.Length. + /// So position < _buffer.Length already holds — the capacity guard IS the bounds check. + /// The JIT cannot see the _bufferEnd <= _buffer.Length relation, so _buffer[position] + /// would emit a second, redundant cmp/jae per write; this helper removes it. DEBUG builds keep + /// an explicit guard so a misbehaving IBinaryOutputBase surfaces loudly, not as corruption. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ref byte BufferAt(int position) + { +#if DEBUG + if ((uint)position >= (uint)_buffer.Length) + throw new InvalidOperationException( + $"BufferAt({position}) out of range — buffer invariant violated " + + $"(_buffer.Length={_buffer.Length}, _bufferEnd={_bufferEnd})."); +#endif + return ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_buffer), (nint)(uint)position); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnsureCapacity(int additionalBytes) { @@ -479,7 +504,7 @@ public static partial class AcBinarySerializer public void WriteByte(byte value) { if (_position >= _bufferEnd) GrowOne(); - _buffer[_position++] = value; + BufferAt(_position++) = value; } /// @@ -504,8 +529,8 @@ public static partial class AcBinarySerializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteTwoBytesUnsafe(byte b1, byte b2) { - _buffer[_position++] = b1; - _buffer[_position++] = b2; + BufferAt(_position++) = b1; + BufferAt(_position++) = b2; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -534,7 +559,7 @@ public static partial class AcBinarySerializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteRawUnsafe(T value) where T : unmanaged { - Unsafe.WriteUnaligned(ref _buffer[_position], value); + Unsafe.WriteUnaligned(ref BufferAt(_position), value); _position += Unsafe.SizeOf(); } @@ -549,8 +574,8 @@ public static partial class AcBinarySerializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteTypeCodeAndRawUnsafe(byte typeCode, T value) where T : unmanaged { - _buffer[_position++] = typeCode; - Unsafe.WriteUnaligned(ref _buffer[_position], value); + BufferAt(_position++) = typeCode; + Unsafe.WriteUnaligned(ref BufferAt(_position), value); _position += Unsafe.SizeOf(); } @@ -565,7 +590,7 @@ public static partial class AcBinarySerializer if (value < 0x80) { if (_position >= _bufferEnd) GrowOne(); - _buffer[_position++] = (byte)value; + BufferAt(_position++) = (byte)value; return; } EnsureCapacity(5); @@ -578,7 +603,7 @@ public static partial class AcBinarySerializer { if (value < 0x80) { - _buffer[_position++] = (byte)value; + BufferAt(_position++) = (byte)value; return; } WriteVarUIntMultiByteUnsafe(value); @@ -589,10 +614,10 @@ public static partial class AcBinarySerializer { while (value >= 0x80) { - _buffer[_position++] = (byte)(value | 0x80); + BufferAt(_position++) = (byte)(value | 0x80); value >>= 7; } - _buffer[_position++] = (byte)value; + BufferAt(_position++) = (byte)value; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -631,7 +656,7 @@ public static partial class AcBinarySerializer if (value < 0x80) { if (_position >= _bufferEnd) GrowOne(); - _buffer[_position++] = (byte)value; + BufferAt(_position++) = (byte)value; return; } EnsureCapacity(10); @@ -644,7 +669,7 @@ public static partial class AcBinarySerializer { if (value < 0x80) { - _buffer[_position++] = (byte)value; + BufferAt(_position++) = (byte)value; return; } WriteVarULongMultiByteUnsafe(value); @@ -655,10 +680,10 @@ public static partial class AcBinarySerializer { while (value >= 0x80) { - _buffer[_position++] = (byte)(value | 0x80); + BufferAt(_position++) = (byte)(value | 0x80); value >>= 7; } - _buffer[_position++] = (byte)value; + BufferAt(_position++) = (byte)value; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -709,8 +734,8 @@ public static partial class AcBinarySerializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteDateTimeBitsUnsafe(DateTime value) { - Unsafe.WriteUnaligned(ref _buffer[_position], value.Ticks); - _buffer[_position + 8] = (byte)value.Kind; + Unsafe.WriteUnaligned(ref BufferAt(_position), value.Ticks); + BufferAt(_position + 8) = (byte)value.Kind; _position += 9; } @@ -740,8 +765,8 @@ public static partial class AcBinarySerializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteDateTimeOffsetBitsUnsafe(DateTimeOffset value) { - Unsafe.WriteUnaligned(ref _buffer[_position], value.UtcTicks); - Unsafe.WriteUnaligned(ref _buffer[_position + 8], (short)value.Offset.TotalMinutes); + Unsafe.WriteUnaligned(ref BufferAt(_position), value.UtcTicks); + Unsafe.WriteUnaligned(ref BufferAt(_position + 8), (short)value.Offset.TotalMinutes); _position += 10; } @@ -766,7 +791,7 @@ public static partial class AcBinarySerializer var byteLenF = charLength * 2; EnsureCapacity(4 + byteLenF); - Unsafe.WriteUnaligned(ref _buffer[_position], charLength); + Unsafe.WriteUnaligned(ref BufferAt(_position), charLength); _position += 4; MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLenF)); _position += byteLenF; @@ -831,8 +856,7 @@ public static partial class AcBinarySerializer } /// - /// Writes a non-empty string with marker-dispatch: detects ASCII vs non-ASCII in-place from - /// the encoder's byte count, then emits the appropriate wire marker: + /// Writes a non-empty string with marker-dispatch — emits the appropriate wire marker: /// /// ASCII ≤ 31 byte → FixStrAscii (1-byte header, length in marker) /// ASCII > 31 byte → StringAscii (1+VarUInt header) @@ -843,174 +867,117 @@ public static partial class AcBinarySerializer /// /// /// H2Q6 wire format v3 — non-ASCII tiers carry both charLen and utf8Len in the header, - /// enabling 1-pass deserialize (no CountUtf8Chars Pass 1). ASCII path unchanged from M3R7. + /// enabling 1-pass deserialize (no CountUtf8Chars Pass 1). Wire output is unchanged. /// - /// Optimistic encode position is chosen by tier-prediction from charLength - /// (worst-case 4 byte/char): ≤ 63 char → Small (3-byte header reserved); ≤ 16383 char → Medium - /// (5-byte header reserved); else Big (9-byte). After encoding, bytesWritten determines - /// the actual tier and the body is left-shifted only if the actual header is smaller than - /// reserved (rare on Magyar text — short Hungarian content stays in Small tier with 0 shift). + /// ASCII-predict, single encode pass. The body is UTF-8-encoded once with + /// Utf8.FromUtf16 straight onto the ASCII-optimistic offset savedPos + asciiHeader, + /// where asciiHeader is the EXACT header an all-ASCII string needs — FixStrAscii = 1 byte, + /// StringAscii = 1 + VarUInt(charLength) (ASCII ⇒ utf8Len == charLength, so the VarUInt + /// width is known pre-encode). bytesWritten == charLength ⇒ pure ASCII ⇒ the body is already + /// at its final offset → zero body-shift (the common case). A non-ASCII string needs the + /// larger 3/5/9 tier header, so shifts the body right by a few + /// bytes — the same single memcpy, moved off the common path onto the rare one. Never encodes twice. /// - /// FastWire mode: re-uses the StringSmall marker value (91) as a generic - /// "string marker" — body layout differs (UTF-16 raw + VarUInt charCount) and the reader - /// dispatches by serializer mode, NOT by re-interpreting the marker. The 91 value is - /// mode-shared because the wire envelope is mode-tagged at the header level. + /// The prior design reserved the non-ASCII header (3/5/9) up-front and left-shifted the body + /// on every ASCII string — penalising the common case to spare the rare one. This reverses it. /// - /// Caller MUST guarantee non-empty input (value.Length > 0) — empty strings - /// are handled by the higher-level WriteString via the StringEmpty marker. + /// Caller MUST guarantee non-empty input (value.Length > 0) — empty strings are + /// handled by the higher-level WriteString via the StringEmpty marker. FastWire never + /// reaches here — callers take the markerless UTF-16 path via WriteStringUtf16Markerless first. /// - // V4N4 method-split reverted (2026-05-07): the split (Writer dispatcher + SmallFast + DispatchLong - // + FastWire) was tested 2026-05-07 in two configurations (15:13:39 AggressiveInlining → regression; - // 15:29:21 NoInlining-on-SmallFast → marginal/inconsistent). Bench-to-bench variance proved - // unmeasurable on the available hardware — the optimization-value signal is below the noise floor. - // Reverted to the single-method state (matches 09:39:09 baseline). The A-direction packed-header - // store optimization (Unsafe.WriteUnaligned ushort/uint/ulong) is preserved — it was already in the - // 09:39:09 baseline and is instruction-level, not affected by AOT inline-pressure variance. + // Hot/cold split (mirrors the reader-side TryReadStringProperty/TryReadStringColdPath, K9M3): the + // AggressiveInlining hot entry keeps the encode + the zero-shift ASCII header inline; the rarer + // non-ASCII tiers (Small/Medium/Big) — which need a body right-shift — move to the [NoInlining] + // WriteStringNonAsciiTail. WriteStringWithDispatch is the shared string-write chokepoint — SGen + // WriteProperties AND runtime WritePropertyOrSkip / TryWritePrimitive all funnel here. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteStringWithDispatch(string value) { var charLength = value.Length; + +#if DEBUG // Overflow guard (O7G2) — predict-friendly (always false on realistic input). NoInlining throw helper. if ((uint)charLength > BinaryTypeCode.MaxStringCharLength) ThrowStringTooLong(charLength); +#endif - if (FastWire) - { - // FastWire markerless: int32 sentinel (-1 = null, 0 = empty, N > 0 = content + N*2 UTF-16 bytes). - // All FastWire string writes funnel through here (WriteStringGenerated → WriteString → - // WriteStringWithDispatch + WritePropertyOrSkip String case + TryWritePrimitive String case), - // so a single change here propagates markerless wire to property + collection + dictionary + - // runtime paths. Caller (WriteString) guarantees value is non-empty content; null/empty - // sentinel encoding lives inside `WriteStringUtf16Markerless` for direct callers. - WriteStringUtf16Markerless(value); - return; - } - - // Compact mode — H2Q6 post-encode tier dispatch (wire-optimal). - // - // Two-step tier logic: - // 1. reserveHeader (from charLength, worst-case 4 byte/char): bounds buffer allocation - // AND encode position. Tight reserve (3/5/9) avoids large memmove on the hot path. - // 2. actualHeader (from bytesWritten after encode): chooses the smallest fitting tier. - // A mostly-ASCII string in the 64-16383 char band gets Small (3 byte header) even though - // reserve was Medium (5 byte) — body is left-shifted by 2 bytes to compact. var maxBytes = charLength * 4; - int reserveHeader = charLength switch - { - <= 63 => 3, - <= 16383 => 5, - _ => 9 - }; + // ASCII-optimistic reserve: the EXACT header an all-ASCII string needs (FixStrAscii = 1, + // StringAscii = 1 + VarUInt(charLength)). Capacity covers the non-ASCII Big-tier worst case + // (9-byte header) so the right-shift in WriteStringNonAsciiTail never re-grows. + var asciiHeader = charLength <= BinaryTypeCode.FixStrAsciiMaxLength ? 1 : 1 + VarUIntSize((uint)charLength); + EnsureCapacity(9 + maxBytes); - EnsureCapacity(reserveHeader + maxBytes); + var encodeStart = _position + asciiHeader; - var savedPos = _position; - var encodeStart = savedPos + reserveHeader; + // Single UTF-8 encode (handles ASCII and non-ASCII alike) onto the ASCII-optimistic offset. System.Text.Unicode.Utf8.FromUtf16(value.AsSpan(), _buffer.AsSpan(encodeStart, maxBytes), out _, out var bytesWritten, replaceInvalidSequences: false); if (bytesWritten == charLength) { - // ASCII override — FixStrAscii (≤31) or StringAscii (>31) with compact header - if (bytesWritten <= BinaryTypeCode.FixStrAsciiMaxLength) + // Pure ASCII — body already at its final offset, header is exactly asciiHeader → zero shift. + if (asciiHeader == 1) { - _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(savedPos + 1, bytesWritten)); - _buffer[savedPos] = BinaryTypeCode.EncodeFixStrAscii(bytesWritten); - _position = savedPos + 1 + bytesWritten; + BufferAt(_position) = BinaryTypeCode.EncodeFixStrAscii(charLength); } else { - var actualVarUIntSize = VarUIntSize((uint)bytesWritten); - var asciiHeader = 1 + actualVarUIntSize; - var shift = reserveHeader - asciiHeader; - if (shift > 0) - _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - shift, bytesWritten)); - _buffer[savedPos] = BinaryTypeCode.StringAscii; - _position = savedPos + 1; - WriteVarUIntUnsafe((uint)bytesWritten); - _position += bytesWritten; + BufferAt(_position) = BinaryTypeCode.StringAscii; + + _position++; + WriteVarUIntUnsafe((uint)charLength); // exactly fills [savedPos+1, encodeStart) } + _position = encodeStart + charLength; return; } switch (bytesWritten) { - // Non-ASCII — post-encode tier choice (smallest fitting tier wins). One if-else chain - // per tier; each branch handles shift + header-store + position update inline. - // - // Combined header-store optimization (shift > 0 only): - // When the actual tier downgrades from the predicted reserve (e.g. Medium predicted - // from charLength but Small actual from bytesWritten), the body needs a left-shift - // memcpy. We write the FULL combined header (uint for Small, ulong for Medium) at - // savedPos BEFORE the body memcpy — the slack byte(s) at the high end of the store - // get overwritten by the subsequent memcpy → 1 store instead of 1+N for the header. - // When shift == 0 (predicted tier matches actual), the body is already at its final - // position; a combined store would corrupt the body's first byte(s), so we fall - // back to separate marker + packed-header stores. - // - // Big tier (9-byte header) always has shift == 0 (reserveHeader == actualHeader == 9) - // because Big is the largest tier — no downgrade path possible. The 1-byte marker + - // 8-byte ulong-packed pattern is already minimal (no slack issue, 9 dedicated bytes). case <= 255: - { - // Small tier: 3-byte header [marker:1][charLen:8][utf8Len:8] - var shift = reserveHeader - 3; - if (shift > 0) { - // Combined uint store: 4 bytes physical, 3 bytes meaningful, 1 byte slack - // at savedPos+3 — overwritten by body memcpy below. - var packedFull = (uint)BinaryTypeCode.StringSmall - | ((uint)charLength << 8) - | ((uint)bytesWritten << 16); - Unsafe.WriteUnaligned(ref _buffer[savedPos], packedFull); - _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(savedPos + 3, bytesWritten)); + // Small tier: 3-byte header [marker:1][charLen:8][utf8Len:8] + var shift = 3 - asciiHeader; + if (shift > 0) _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart + shift, bytesWritten)); + + BufferAt(_position) = BinaryTypeCode.StringSmall; + Unsafe.WriteUnaligned(ref BufferAt(++_position), (ushort)(charLength | (bytesWritten << 8))); + + _position = _position + 2 + bytesWritten; + return; } - else - { - // shift == 0: body in place at savedPos+3 = encodeStart. Combined uint store - // would corrupt body's first byte; use separate 1-byte marker + 2-byte packed. - _buffer[savedPos] = BinaryTypeCode.StringSmall; - var packedHl = (ushort)(charLength | (bytesWritten << 8)); - Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packedHl); - } - _position = savedPos + 3 + bytesWritten; - break; - } case <= 65535: - { - // Medium tier: 5-byte header [marker:1][charLen:16][utf8Len:16] - var shift = reserveHeader - 5; - if (shift > 0) { - // Combined ulong store: 8 bytes physical, 5 bytes meaningful, 3 bytes slack - // at savedPos+5..7 — overwritten by body memcpy below. - var packedFull = (ulong)BinaryTypeCode.StringMedium - | ((ulong)(uint)charLength << 8) - | ((ulong)(uint)bytesWritten << 24); - Unsafe.WriteUnaligned(ref _buffer[savedPos], packedFull); - _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(savedPos + 5, bytesWritten)); + // Medium tier: 5-byte header [marker:1][charLen:16][utf8Len:16] + var shift = 5 - asciiHeader; + if (shift > 0) _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart + shift, bytesWritten)); + + BufferAt(_position) = BinaryTypeCode.StringMedium; + Unsafe.WriteUnaligned(ref BufferAt(++_position), (uint)charLength | ((uint)bytesWritten << 16)); + + _position = _position + 4 + bytesWritten; + return; } - else - { - // shift == 0: separate 1-byte marker + 4-byte packed. - _buffer[savedPos] = BinaryTypeCode.StringMedium; - var packedHl = (uint)charLength | ((uint)bytesWritten << 16); - Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packedHl); - } - _position = savedPos + 5 + bytesWritten; - break; - } default: - { - // Big tier: 9-byte header [marker:1][charLen:32][utf8Len:32]. shift always 0. - _buffer[savedPos] = BinaryTypeCode.StringBig; - var packed = (ulong)(uint)charLength | ((ulong)(uint)bytesWritten << 32); - Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packed); - _position = savedPos + 9 + bytesWritten; - break; - } + { + WriteStringBigTierColdPath(encodeStart, charLength, bytesWritten, 9 - asciiHeader); + return; + } } } + [MethodImpl(MethodImplOptions.NoInlining)] + private void WriteStringBigTierColdPath(int encodeStart, int charLength, int bytesWritten, int shift) + { + // Big tier: 9-byte header [marker:1][charLen:32][utf8Len:32] + if (shift > 0) _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart + shift, bytesWritten)); + + BufferAt(_position) = BinaryTypeCode.StringBig; + Unsafe.WriteUnaligned(ref BufferAt(++_position), (uint)charLength | ((ulong)(uint)bytesWritten << 32)); + + _position = _position + 8 + bytesWritten; + } + /// /// Writes the first-occurrence body of an interned string with H2Q6 tier-marker dispatch. /// Used by the runtime/SGen string-intern write path; subsequent occurrences use cache-index ref. @@ -1060,7 +1027,7 @@ public static partial class AcBinarySerializer _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - shift, bytesWritten)); // Write [marker][cacheIdx VarUInt][charLen + utf8Len header][bytes] - _buffer[savedPos] = tierMarker; + BufferAt(savedPos) = tierMarker; _position = savedPos + 1; WriteVarUIntUnsafe((uint)cacheMapIndex); @@ -1069,20 +1036,68 @@ public static partial class AcBinarySerializer { // Pack charLen:8 | utf8Len:8 → single ushort store var packed = (ushort)(charLength | (bytesWritten << 8)); - Unsafe.WriteUnaligned(ref _buffer[_position], packed); + Unsafe.WriteUnaligned(ref BufferAt(_position), packed); _position += 2; } else { // Pack charLen:16 | utf8Len:16 → single uint store, LE var packed = (uint)charLength | ((uint)bytesWritten << 16); - Unsafe.WriteUnaligned(ref _buffer[_position], packed); + Unsafe.WriteUnaligned(ref BufferAt(_position), packed); _position += 4; } _position += bytesWritten; } + /// + /// Attempts to write through the string-interning protocol. + /// Reads and immediately resets ; when the property is + /// intern-eligible and the write-plan cursor yields an entry, emits the InternFirst / + /// InternRef wire form and returns true. Returns false when the string must + /// be written by the regular tier dispatch — the caller then invokes + /// . + /// + /// + /// Extracted from the runtime WriteString interning block (K9M3-style hoist) so the + /// SGen string-property emit can call it directly — no WriteStringGenerated / + /// WriteString hop. Caller guarantees non-null, non-empty content. The unconditional + /// flag reset prevents the per-property from leaking into + /// subsequent string writes. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryWriteInternedString(string value) + { + // Read and immediately reset — prevents the flag leaking to subsequent string writes + // (TryWritePrimitive, WriteDictionary, or when IsValidForInterningString is false). + var internEligible = StringInternEligible; + StringInternEligible = false; + + if (internEligible && IsValidForInterningString(value.Length)) + { + if (TryConsumeWritePlanEntry(out var planEntry)) + { + ValidateWritePlanString(in planEntry, value); + if (planEntry.IsFirst) + { + // H2Q6 v3 — StringInternFirst tier-marker dispatch (Small/Medium); charLen + // carried in header → 1-pass decode, no CountUtf8Chars Pass 1. + WriteStringInternFirstWithDispatch(planEntry.Value ?? value, planEntry.CacheMapIndex); + } + else + { + WriteStringInternRef(this, planEntry.CacheMapIndex); + } + return true; + } + // No plan entry → single occurrence, caller falls through to the tier dispatch. +#if DEBUG + OnStringInterned?.Invoke(null, value); +#endif + } + return false; + } + // ───────────────────────────────────────────────────────────────── // V4N5 dead-code cleanup (2026-05-06): WriteFixStr, WriteFixStrDirect, WriteFixStrBytes, // WritePreencodedPropertyName, and WriteStringUtf8Internal removed — these were unreachable @@ -1124,8 +1139,8 @@ public static partial class AcBinarySerializer EnsureCapacity(span.Length * 9); for (var i = 0; i < span.Length; i++) { - _buffer[_position++] = BinaryTypeCode.Float64; - Unsafe.WriteUnaligned(ref _buffer[_position], span[i]); + BufferAt(_position++) = BinaryTypeCode.Float64; + Unsafe.WriteUnaligned(ref BufferAt(_position), span[i]); _position += 8; } } @@ -1135,8 +1150,8 @@ public static partial class AcBinarySerializer EnsureCapacity(span.Length * 5); for (var i = 0; i < span.Length; i++) { - _buffer[_position++] = BinaryTypeCode.Float32; - Unsafe.WriteUnaligned(ref _buffer[_position], span[i]); + BufferAt(_position++) = BinaryTypeCode.Float32; + Unsafe.WriteUnaligned(ref BufferAt(_position), span[i]); _position += 4; } } @@ -1146,7 +1161,7 @@ public static partial class AcBinarySerializer EnsureCapacity(span.Length * 17); for (var i = 0; i < span.Length; i++) { - _buffer[_position++] = BinaryTypeCode.Guid; + BufferAt(_position++) = BinaryTypeCode.Guid; span[i].TryWriteBytes(_buffer.AsSpan(_position, 16)); _position += 16; } @@ -1458,7 +1473,7 @@ public static partial class AcBinarySerializer if (ReferenceHandling == ReferenceHandlingMode.OnlyId) flags |= BinaryTypeCode.HeaderFlag_RefHandling_OnlyId; else if (ReferenceHandling == ReferenceHandlingMode.All) - flags |= (byte)(BinaryTypeCode.HeaderFlag_RefHandling_OnlyId | BinaryTypeCode.HeaderFlag_RefHandling_All); + flags |= BinaryTypeCode.HeaderFlag_RefHandling_OnlyId | BinaryTypeCode.HeaderFlag_RefHandling_All; if (HasCaching) flags |= BinaryTypeCode.HeaderFlag_HasCacheCount; diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index 2974af0..109cb04 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -1387,41 +1387,26 @@ public static partial class AcBinarySerializer private static void WriteString(string value, BinarySerializationContext context) where TOutput : struct, IBinaryOutputBase { + if (context.FastWire) + { + // FastWire: markerless UTF-16 — WriteStringUtf16Markerless handles null / empty / content + // via the int32 sentinel header. Interning is intentionally skipped in FastWire today + // (BINARY_ISSUES.md#accore-bin-i-k3w8 — to be reconciled separately). + context.WriteStringUtf16Markerless(value); + return; + } + if (value.Length == 0) { context.WriteByte(BinaryTypeCode.StringEmpty); return; } - // Read and immediately reset — prevents flag from leaking to subsequent WriteString calls - // (e.g. from TryWritePrimitive, WriteDictionary, or when IsValidForInterningString is false) - var internEligible = context.StringInternEligible; - context.StringInternEligible = false; - - if (internEligible && context.IsValidForInterningString(value.Length)) - { - if (context.TryConsumeWritePlanEntry(out var planEntry)) - { - ValidateWritePlanString(in planEntry, value); - if (planEntry.IsFirst) - { - // H2Q6 v3 wire format — StringFirst with tier-marker dispatch (Small/Medium): - // [StringInternFirstSmall][cacheIdx:VarUInt][charLen:8][utf8Len:8][bytes] if utf8Len ≤ 255 - // [StringInternFirstMedium][cacheIdx:VarUInt][charLen:16][utf8Len:16][bytes] if utf8Len ≤ 65535 - // 1-pass decode: charLen carried in header, no CountUtf8Chars Pass 1. - context.WriteStringInternFirstWithDispatch(planEntry.Value ?? value, planEntry.CacheMapIndex); - } - else - { - WriteStringInternRef(context, planEntry.CacheMapIndex); - } - return; - } - // No plan entry → single occurrence, fall through to FixStr/String path -#if DEBUG - context.OnStringInterned?.Invoke(null, value); -#endif - } + // Interning attempt — TryWriteInternedString reads + resets StringInternEligible and emits + // the InternFirst / InternRef wire form when the write-plan cursor yields an entry. Returns + // false (string not interned) → fall through to the tier dispatch below. + if (context.TryWriteInternedString(value)) + return; // Marker-dispatch (ACCORE-BIN-T-M3R7): WriteStringWithDispatch encodes UTF-8 once, detects // ASCII via bytesWritten == charLength, and emits the optimal wire marker: @@ -1430,7 +1415,6 @@ public static partial class AcBinarySerializer // • bytesWritten > 31 + ASCII → StringAscii (marker + VarUInt length + ASCII payload) // • bytesWritten > 31 + UTF-8 → String (marker + VarUInt length + UTF-8 payload) // Reader dispatches on the ASCII marker to skip UTF-8 decode (byte→char widen only). - // FastWire path is handled inside WriteStringWithDispatch (no marker dispatch — UTF-16 raw). context.WriteStringWithDispatch(value); } diff --git a/AyCode.Core/docs/BINARY/BINARY_ISSUES.md b/AyCode.Core/docs/BINARY/BINARY_ISSUES.md index 75360ac..c0e939c 100644 --- a/AyCode.Core/docs/BINARY/BINARY_ISSUES.md +++ b/AyCode.Core/docs/BINARY/BINARY_ISSUES.md @@ -68,6 +68,17 @@ When the previous `PipeWriter.FlushAsync()` hasn't completed by the next `Grow() Same `TryGetArray` fallback as `BufferWriterBinaryOutput` (ACCORE-BIN-I-K8R4). Kestrel `PipeWriter.GetMemory()` always returns array-backed memory — fallback is for non-standard `PipeWriter` implementations only. +### ACCORE-BIN-I-K3W8: FastWire skips string interning → shared write-plan cursor desyncs ref handling + +**Status:** Open · **Severity:** Major (latent — silent data corruption) +**Affects:** `WireMode.Fast` + a type with BOTH `[AcStringIntern]` properties AND reference handling. + +The FastWire string path (`WriteStringGenerated` / `WritePropertyOrSkip` String case) early-returns via `WriteStringUtf16Markerless` and never reaches `WriteString`'s interning block → **string interning is skipped in FastWire**. But the scan pass is FastWire-agnostic and still emits string-intern entries into the write plan, which is **shared** (VisitIndex-ordered, single cursor) with reference handling. FastWire never consumes its string entries → the shared cursor (`TryConsumeWritePlanEntry`) **desyncs** → ref handling reads the wrong entry → shared objects duplicated / object identity lost. Compact mode is fine → latent (the benchmark uses Compact, so it has gone undetected). + +**Fix direction:** FastWire must be purely a string byte-encoding choice (UTF-16 raw vs UTF-8 markered) — interning + ref handling must behave identically to Compact. Route FastWire string writes through the interning protocol (InternFirst/InternRef markers, cache, write-plan consumption); only the first-occurrence content bytes differ. Drop the early-return. Conceptually this is the long-proposed `WireMode` → `StringEncoding` rename. + +**Note:** the cursor-desync mechanism is strongly inferred (FastWire-gate grep + the shared write-plan design) — confirm against `TryConsumeWritePlanEntry` + the scan pass before implementing the fix. + ## Deserialization (PipeReader) ### ACCORE-BIN-I-B4Y7: PipeReaderBinaryInput uses sync ReadAsync().GetResult()