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