Refactor string serialization, CLI args, and test data

- Refactored string serialization for performance: ASCII-optimistic encode, single pass, and minimal shifting; extracted string interning logic to TryWriteInternedString for both runtime and SGen paths.
- Updated AcBinarySerializer buffer writes to use BufferAt helper, removing redundant bounds checks.
- Enhanced CLI argument parsing to support multiple args and charset selection; unknown args now emit warnings.
- Switched all test data generation from Hungarian to English.
- Benchmark report now includes .NET runtime version.
- Cached MinStringInternLength in AcBinaryDeserializer for performance.
- Minor BinaryTypeCode flag refactor and doc improvements.
- Added BINARY_ISSUES.md entry for FastWire string interning/ref handling desync bug.
This commit is contained in:
Loretta 2026-05-21 21:03:03 +02:00
parent 7fb74dbbb0
commit 3adad03f15
10 changed files with 341 additions and 270 deletions

View File

@ -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)"
]
}
}

View File

@ -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/.

View File

@ -75,14 +75,15 @@ public static class Program
}
/// <summary>
/// Parses CLI arguments into (layer, opMode, serializerMode). Uses <see cref="Enum.TryParse{T}(string, bool, out T)"/>
/// case-insensitive against the three enums in this order: <see cref="SerializerSelectionMode"/> (matches
/// "FastestByte"/"AsyncPipe"), <see cref="BenchmarkOpMode"/> (matches "Serialize"/"Deserialize"), then
/// <see cref="BenchmarkLayer"/> (matches "Core"/"Comprehensive"/"Edge"/"Small"/...). Special-cased:
/// <c>"quick"</c> mutates <see cref="Configuration"/> 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 <see cref="BenchmarkLoop.FilterByLayer"/>'s default branch (full unfiltered suite).
/// Returns <c>false</c> only if the caller-side path would need to abort; currently always returns <c>true</c>.
/// Parses CLI arguments into (layer, opMode, serializerMode) and, as a side effect, the active
/// charset (<see cref="BenchmarkTestDataProvider.LongStringSuffix"/>). Each arg is classified
/// independently and case-insensitively, so multiple args combine in any order — e.g.
/// <c>FastestByte AsciiShort</c> or <c>Serialize Large Latin1Short</c>. Per arg, in order:
/// <c>"quick"</c> (mutates <see cref="Configuration"/> warmup/iter counts), <see cref="SerializerSelectionMode"/>,
/// <see cref="BenchmarkOpMode"/>, <see cref="BenchmarkLayer"/>, then a charset name
/// (see <see cref="TryApplyCharsetArg"/>). Unrecognized args are warned and ignored; dimensions left
/// unset keep their defaults (All, All, Standard, and the <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
/// field default for charset). Always returns <c>true</c> (kept for caller-side abort symmetry).
/// </summary>
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<SerializerSelectionMode>(arg, ignoreCase: true, out var sm))
{
serializerMode = sm;
continue;
}
// Op-mode (Serialize/Deserialize/All).
if (Enum.TryParse<BenchmarkOpMode>(arg, ignoreCase: true, out var om))
{
opMode = om;
continue;
}
// Layer (Core/Comprehensive/Edge/Small/Medium/Large/Repeated/Deep/All).
if (Enum.TryParse<BenchmarkLayer>(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<SerializerSelectionMode>(arg, ignoreCase: true, out var sm))
{
serializerMode = sm;
return true;
}
return true;
}
// Op-mode (Serialize/Deserialize/All).
if (Enum.TryParse<BenchmarkOpMode>(arg, ignoreCase: true, out var om))
/// <summary>
/// Maps a case-insensitive charset name to its <see cref="CharsetSuffixes"/> value and assigns
/// <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>. Names mirror the interactive
/// <c>ShowCharsetSettingsMenu</c> options. <see cref="CharsetSuffixes"/> members are <c>const string</c>,
/// so this is a name→value match rather than an <see cref="Enum.TryParse{T}(string, bool, out T)"/>.
/// Returns <c>false</c> when the name is not a known charset (the caller then treats the arg as unknown).
/// </summary>
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<BenchmarkLayer>(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;
}

View File

@ -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

View File

@ -52,12 +52,12 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
return new TTag
{
Id = id,
Name = name ?? $"Címke-{id}",
Color = color ?? $"Szín-#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
Name = name ?? $"Tag-{id}",
Color = color ?? $"Color-#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
Priority = id % 5,
IsActive = id % 2 == 0,
CreatedAt = DateTime.UtcNow.AddDays(-id),
Description = $"Címke leírása {id}"
Description = $"Tag description {id}"
};
}
@ -70,8 +70,8 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
return new TCategory
{
Id = id,
Name = name ?? $"Kategória-{id}",
Description = $"Kategória leírása {id}",
Name = name ?? $"Category-{id}",
Description = $"Category description {id}",
SortOrder = id * 100,
IsDefault = id == 1,
ParentCategoryId = parentId,
@ -89,20 +89,20 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
return new TUser
{
Id = id,
Username = username ?? $"felhasználó{id}",
Email = $"felhasználó{id}@teszt.hu",
FirstName = $"Vezetéknév{id}",
LastName = $"Keresztnév{id}",
Username = username ?? $"user{id}",
Email = $"user{id}@test.com",
FirstName = $"FirstName{id}",
LastName = $"LastName{id}",
IsActive = true,
Role = role,
LastLoginAt = DateTime.UtcNow.AddHours(-id),
CreatedAt = DateTime.UtcNow.AddYears(-1),
Preferences = new TPreferences
{
Theme = id % 2 == 0 ? "sötét" : "világos",
Language = "magyar",
Theme = id % 2 == 0 ? "dark" : "light",
Language = "english",
NotificationsEnabled = true,
EmailDigestFrequency = "naponkénti"
EmailDigestFrequency = "daily"
}
};
}
@ -115,10 +115,10 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
var id = _idCounter++;
return new TMetadata
{
Key = key ?? $"Metaadat-{id}",
Value = $"MetaÉrték-{id}",
Key = key ?? $"Metadata-{id}",
Value = $"MetadataValue-{id}",
Timestamp = DateTime.UtcNow.AddMinutes(-id * 10),
ChildMetadata = withChild ? CreateMetadata($"Gyermek-{id}", false) : null
ChildMetadata = withChild ? CreateMetadata($"Child-{id}", false) : null
};
}
@ -147,7 +147,7 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
var order = new TOrder
{
Id = _idCounter++,
OrderNumber = $"Megrendelés-{_idCounter:D4}",
OrderNumber = $"Order-{_idCounter:D4}",
Status = TestStatus.Pending,
CreatedAt = DateTime.UtcNow,
TotalAmount = 1000m + _idCounter * 100,
@ -207,7 +207,7 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
var item = new TItem
{
Id = _idCounter++,
ProductName = $"Termék-{_idCounter}",
ProductName = $"Product-{_idCounter}",
Quantity = 10 + _idCounter,
UnitPrice = 5.5m * _idCounter,
Status = TestStatus.Pending,
@ -247,7 +247,7 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
var pallet = new TPallet
{
Id = _idCounter++,
PalletCode = $"Raklapkód-{_idCounter:D4}",
PalletCode = $"PalletCode-{_idCounter:D4}",
TrayCount = 5 + _idCounter % 10,
Status = TestStatus.Pending,
Weight = 100.5 + _idCounter,
@ -278,7 +278,7 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
var measurement = new TMeasurement
{
Id = _idCounter++,
Name = $"Mérés-{_idCounter}",
Name = $"Measurement-{_idCounter}",
TotalWeight = 100.5 + _idCounter,
CreatedAt = DateTime.UtcNow,
Tag = sharedTag,
@ -306,7 +306,7 @@ public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoi
return new TPoint
{
Id = id,
Label = $"MérőPont-{id}",
Label = $"MeasurePoint-{id}",
Value = 10.5 + (id * 0.1),
MeasuredAt = DateTime.UtcNow,
Tag = sharedTag,

View File

@ -23,27 +23,24 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// </summary>
public TOptions Options { get; private set; } = null!;
public byte MaxDepth => Options.MaxDepth;
public byte _maxDepth;
public ReferenceHandlingMode ReferenceHandling => Options.ReferenceHandling;
/// <summary>
/// Pre-computed: ReferenceHandling != None. Avoids Options field chain per call.
/// </summary>
private bool _hasRefHandling;
public ReferenceHandlingMode ReferenceHandling;
/// <summary>
/// Pre-computed: ReferenceHandling == OnlyId (not All). When true, only IId types are tracked.
/// </summary>
private bool _hasIdHandling;
/// <summary>
/// Pre-computed: ReferenceHandling != None. Avoids Options field chain per call.
/// </summary>
internal bool HasRefHandling;
/// <summary>
/// Pre-computed: ReferenceHandling == All. When true, all reference types are tracked.
/// </summary>
private bool _hasAllRefHandling;
internal bool HasAllRefHandling;
internal bool HasRefHandling => _hasRefHandling;
internal bool HasAllRefHandling => _hasAllRefHandling;
public bool ThrowOnCircularReference => Options.ThrowOnCircularReference;
/// <summary>
/// Global shared cache for metadata (thread-safe, shared across all contexts).
@ -88,7 +85,7 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
[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<TMetadata, TOptions>
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;
}
/// <summary>

View File

@ -67,7 +67,7 @@ public static partial class AcBinaryDeserializer
public bool FastWire;
// Options-derived properties
public byte MinStringInternLength => Options.MinStringInternLength;
public byte MinStringInternLength;
/// <summary>
/// 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;
}

View File

@ -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
/// <summary>
/// Unchecked <c>ref byte</c> into <see cref="_buffer"/> at <paramref name="position"/> — omits the
/// JIT array bounds-check that a plain <c>_buffer[position]</c> index emits on every write.
/// </summary>
/// <remarks>
/// Safe by the buffer invariant: every write primitive first guarantees <c>position &lt; _bufferEnd</c>
/// (the <c>_position &lt; _bufferEnd</c> grow-guard or <see cref="EnsureCapacity"/>), and <c>_bufferEnd</c>
/// is "one past the last writable index in <c>_buffer</c>" ⇒ <c>_bufferEnd &lt;= _buffer.Length</c>.
/// So <c>position &lt; _buffer.Length</c> already holds — the capacity guard IS the bounds check.
/// The JIT cannot see the <c>_bufferEnd &lt;= _buffer.Length</c> relation, so <c>_buffer[position]</c>
/// would emit a second, redundant <c>cmp/jae</c> per write; this helper removes it. DEBUG builds keep
/// an explicit guard so a misbehaving <c>IBinaryOutputBase</c> surfaces loudly, not as corruption.
/// </remarks>
[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;
}
/// <summary>
@ -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>(T value) where T : unmanaged
{
Unsafe.WriteUnaligned(ref _buffer[_position], value);
Unsafe.WriteUnaligned(ref BufferAt(_position), value);
_position += Unsafe.SizeOf<T>();
}
@ -549,8 +574,8 @@ public static partial class AcBinarySerializer
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteTypeCodeAndRawUnsafe<T>(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<T>();
}
@ -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<int>(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
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item>ASCII ≤ 31 byte → <c>FixStrAscii</c> (1-byte header, length in marker)</item>
/// <item>ASCII &gt; 31 byte → <c>StringAscii</c> (1+VarUInt header)</item>
@ -843,174 +867,117 @@ public static partial class AcBinarySerializer
/// </summary>
/// <remarks>
/// H2Q6 wire format v3 — non-ASCII tiers carry both <c>charLen</c> and <c>utf8Len</c> in the header,
/// enabling 1-pass deserialize (no <c>CountUtf8Chars</c> Pass 1). ASCII path unchanged from M3R7.
/// enabling 1-pass deserialize (no <c>CountUtf8Chars</c> Pass 1). Wire output is unchanged.
///
/// <para>Optimistic encode position is chosen by tier-prediction from <c>charLength</c>
/// (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, <c>bytesWritten</c> 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).</para>
/// <para><b>ASCII-predict, single encode pass.</b> The body is UTF-8-encoded <i>once</i> with
/// <c>Utf8.FromUtf16</c> straight onto the ASCII-optimistic offset <c>savedPos + asciiHeader</c>,
/// where <c>asciiHeader</c> is the EXACT header an all-ASCII string needs — FixStrAscii = 1 byte,
/// StringAscii = <c>1 + VarUInt(charLength)</c> (ASCII ⇒ <c>utf8Len == charLength</c>, so the VarUInt
/// width is known pre-encode). <c>bytesWritten == charLength</c> ⇒ pure ASCII ⇒ the body is already
/// at its final offset → <b>zero body-shift</b> (the common case). A non-ASCII string needs the
/// larger 3/5/9 tier header, so <see cref="WriteStringNonAsciiTail"/> shifts the body right by a few
/// bytes — the same single memcpy, moved off the common path onto the rare one. Never encodes twice.</para>
///
/// <para>FastWire mode: re-uses the <c>StringSmall</c> 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.</para>
/// <para>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.</para>
///
/// <para>Caller MUST guarantee non-empty input (<c>value.Length &gt; 0</c>) — empty strings
/// are handled by the higher-level <c>WriteString</c> via the <c>StringEmpty</c> marker.</para>
/// <para>Caller MUST guarantee non-empty input (<c>value.Length &gt; 0</c>) — empty strings are
/// handled by the higher-level <c>WriteString</c> via the <c>StringEmpty</c> marker. FastWire never
/// reaches here — callers take the markerless UTF-16 path via <c>WriteStringUtf16Markerless</c> first.</para>
/// </remarks>
// 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<uint>(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<ushort>(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<ulong>(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<uint>(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<ulong>(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;
}
/// <summary>
/// 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<ushort>(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<uint>(ref _buffer[_position], packed);
Unsafe.WriteUnaligned(ref BufferAt(_position), packed);
_position += 4;
}
_position += bytesWritten;
}
/// <summary>
/// Attempts to write <paramref name="value"/> through the string-interning protocol.
/// Reads and immediately resets <see cref="StringInternEligible"/>; when the property is
/// intern-eligible and the write-plan cursor yields an entry, emits the InternFirst /
/// InternRef wire form and returns <c>true</c>. Returns <c>false</c> when the string must
/// be written by the regular tier dispatch — the caller then invokes
/// <see cref="WriteStringWithDispatch"/>.
/// </summary>
/// <remarks>
/// Extracted from the runtime <c>WriteString</c> interning block (K9M3-style hoist) so the
/// SGen string-property emit can call it directly — no <c>WriteStringGenerated</c> /
/// <c>WriteString</c> hop. Caller guarantees non-null, non-empty content. The unconditional
/// flag reset prevents the per-property <see cref="StringInternEligible"/> from leaking into
/// subsequent string writes.
/// </remarks>
[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;

View File

@ -1387,41 +1387,26 @@ public static partial class AcBinarySerializer
private static void WriteString<TOutput>(string value, BinarySerializationContext<TOutput> 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);
}

File diff suppressed because one or more lines are too long