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:
parent
7fb74dbbb0
commit
3adad03f15
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 < _bufferEnd</c>
|
||||
/// (the <c>_position < _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 <= _buffer.Length</c>.
|
||||
/// So <c>position < _buffer.Length</c> already holds — the capacity guard IS the bounds check.
|
||||
/// The JIT cannot see the <c>_bufferEnd <= _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 > 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 > 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 > 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue