Refactor: hoist interned string decode to context methods

- Moved interned string decode logic to BinaryDeserializationContext instance methods, reducing duplication and unifying SGen, runtime, and cross-type paths.
- Updated SGen-emitted code and TypeReaderTable to use new context methods.
- Added performance TODO (ACCORE-BIN-T-K9M3) documenting rationale and acceptance.
- Clarified AcBinarySerializableAttribute XML docs.
- Added repo-scoped nuget.config for deterministic restore.
- Updated settings.local.json with new Bash/dev commands.
- Minor code and comment cleanups for clarity.
This commit is contained in:
Loretta 2026-05-15 10:14:19 +02:00
parent 67b04612a4
commit 8293a6edd1
8 changed files with 178 additions and 83 deletions

View File

@ -83,7 +83,9 @@
"Bash(dotnet test *)",
"Read(//tmp/**)",
"Bash(git -C H:/Applications/Aycode/Source/AyCode.Core log -p --all -S \"MaxDepth\" -- AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs)",
"Bash(git -C H:/Applications/Aycode/Source/AyCode.Core show ac6e66f^:AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs)"
"Bash(git -C H:/Applications/Aycode/Source/AyCode.Core show ac6e66f^:AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs)",
"Bash(ls -la \"H:\\\\Applications\\\\Aycode\\\\Source\\\\\" 2>&1 && echo \"---\" && ls -la \"H:\\\\Applications\\\\Aycode\\\\\" 2>&1)",
"Bash(dotnet publish *)"
]
}
}

View File

@ -2008,32 +2008,14 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine($"{i} {a} = salen == 0 ? string.Empty : context.ReadAsciiBytesAsString(salen);");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
// H2Q6 interning — Small tier
// H2Q6 interning — Small / Medium tiers. Wire-decode body is shared with the runtime path
// (TypeReaderTable + cross-type populate) — see context.ReadAndRegisterInternedStringSmall/Medium.
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstSmall:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context.DisableStringCaching();");
sb.AppendLine($"{i} var iscIdx = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var ishdr = context.ReadTwoBytesUnsafe();");
sb.AppendLine($"{i} var ischarLen = (byte)ishdr;");
sb.AppendLine($"{i} var isbyteLen = (byte)(ishdr >> 8);");
sb.AppendLine($"{i} var isv = isbyteLen == 0 ? string.Empty : context.ReadStringUtf8WithCharLen(ischarLen, isbyteLen);");
sb.AppendLine($"{i} context.RegisterInternedValueAt(iscIdx, isv);");
sb.AppendLine($"{i} {a} = isv;");
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringSmall();");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
// H2Q6 interning — Medium tier — single uint header read
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstMedium:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context.DisableStringCaching();");
sb.AppendLine($"{i} var imcIdx = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var impacked = context.ReadUInt32Unsafe();");
sb.AppendLine($"{i} var imcharLen = (ushort)impacked;");
sb.AppendLine($"{i} var imbyteLen = (ushort)(impacked >> 16);");
sb.AppendLine($"{i} var imv = imbyteLen == 0 ? string.Empty : context.ReadStringUtf8WithCharLen(imcharLen, imbyteLen);");
sb.AppendLine($"{i} context.RegisterInternedValueAt(imcIdx, imv);");
sb.AppendLine($"{i} {a} = imv;");
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringMedium();");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
sb.AppendLine($"{i} {a} = null;");
sb.AppendLine($"{i} break;");

View File

@ -98,8 +98,7 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// The wrapper contains metadata (from GlobalMetadataCache) + per-context tracking state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TypeMetadataWrapper<TMetadata> GetWrapper(
[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type)
public TypeMetadataWrapper<TMetadata> GetWrapper([DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type)
{
if (_wrappers.TryGetValue(type, out var wrapper))
return wrapper;
@ -108,8 +107,7 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
}
[MethodImpl(MethodImplOptions.NoInlining)]
private TypeMetadataWrapper<TMetadata> GetWrapperSlow(
[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type)
private TypeMetadataWrapper<TMetadata> GetWrapperSlow([DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type)
{
// Get metadata from global cache (thread-safe).
// ConcurrentDictionary.GetOrAdd's Func<TKey, TValue> overload drops DAMs at the delegate

View File

@ -27,9 +27,8 @@ namespace AyCode.Core.Serializers.Attributes;
/// </list>
///
/// <para>Set a flag to <c>false</c> only when you can guarantee no consumer will ever need that
/// feature on this type (typical for high-throughput message DTOs where wire size and serialize
/// CPU dominate, and a feature like <c>PropertyFilter</c> is genuinely never used). Otherwise leave
/// it at the default <c>true</c>.</para>
/// feature on this type AND the marginal hot-path speedup matters for the workload. Otherwise
/// leave it at the default <c>true</c>.</para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
public sealed class AcBinarySerializableAttribute : Attribute

View File

@ -619,6 +619,57 @@ public static partial class AcBinaryDeserializer
return value;
}
/// <summary>
/// H2Q6 StringInternFirstSmall reader: wire <c>[cacheIdx:VarUInt][charLen:8][utf8Len:8][bytes]</c>
/// after the marker has been consumed. Registers the decoded string in the intern cache and returns it.
/// Single source of wire-decode for this marker — shared by the runtime <c>TypeReaderTable</c>
/// dispatch, the cross-type populate path, and the SGen-emitted string-property switch.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal string ReadAndRegisterInternedStringSmall()
{
// First interning marker proves payload uses string interning → plain String entries
// appear only once, so _stringCache would never hit on them.
DisableStringCaching();
var cacheIndex = (int)ReadVarUInt();
var header = ReadTwoBytesUnsafe();
var charLength = (byte)header;
var byteLength = (byte)(header >> 8);
if (byteLength == 0)
{
RegisterInternedValueAt(cacheIndex, string.Empty);
return string.Empty;
}
var str = ReadStringUtf8WithCharLen(charLength, byteLength);
RegisterInternedValueAt(cacheIndex, str);
return str;
}
/// <summary>
/// H2Q6 StringInternFirstMedium reader: wire <c>[cacheIdx:VarUInt][charLen:16 LE][utf8Len:16 LE][bytes]</c>.
/// Registers the decoded string in the intern cache and returns it. (Big tier never engages on the
/// interning path — see <see cref="BinaryTypeCode"/> H2Q6 layout comment.) Shared by runtime
/// dispatch + SGen-emit (same rationale as <see cref="ReadAndRegisterInternedStringSmall"/>).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal string ReadAndRegisterInternedStringMedium()
{
DisableStringCaching();
var cacheIndex = (int)ReadVarUInt();
// Pack charLen:16 | utf8Len:16 read in a single uint load
var packed = ReadUInt32Unsafe();
var charLength = (ushort)packed;
var byteLength = (ushort)(packed >> 16);
if (byteLength == 0)
{
RegisterInternedValueAt(cacheIndex, string.Empty);
return string.Empty;
}
var str = ReadStringUtf8WithCharLen(charLength, byteLength);
RegisterInternedValueAt(cacheIndex, str);
return str;
}
/// <summary>
/// Full-content hash for string caching.
/// CRITICAL: DO NOT SIMPLIFY <20> prevents hash collisions for similar property names.

View File

@ -105,8 +105,8 @@ public static partial class AcBinaryDeserializer
readers[BinaryTypeCode.StringInterned] = static (ctx, _) => ctx.GetInternedString((int)ctx.ReadVarUInt());
readers[BinaryTypeCode.StringEmpty] = static (_, _) => string.Empty;
// H2Q6 interning tier readers (Compact mode only — Big tier never engages on interning path)
readers[BinaryTypeCode.StringInternFirstSmall] = static (ctx, _) => ReadAndRegisterInternedStringSmall(ctx);
readers[BinaryTypeCode.StringInternFirstMedium] = static (ctx, _) => ReadAndRegisterInternedStringMedium(ctx);
readers[BinaryTypeCode.StringInternFirstSmall] = static (ctx, _) => ctx.ReadAndRegisterInternedStringSmall();
readers[BinaryTypeCode.StringInternFirstMedium] = static (ctx, _) => ctx.ReadAndRegisterInternedStringMedium();
readers[BinaryTypeCode.StringAscii] = static (ctx, _) => ReadPlainStringAscii(ctx);
readers[BinaryTypeCode.DateTime] = static (ctx, _) => ctx.ReadDateTimeUnsafe();
readers[BinaryTypeCode.DateTimeOffset] = static (ctx, _) => ctx.ReadDateTimeOffsetUnsafe();
@ -1067,10 +1067,10 @@ public static partial class AcBinaryDeserializer
propInfo.SetValue(target, context.GetInternedString((int)context.ReadVarUInt()));
return true;
case BinaryTypeCode.StringInternFirstSmall:
propInfo.SetValue(target, ReadAndRegisterInternedStringSmall(context));
propInfo.SetValue(target, context.ReadAndRegisterInternedStringSmall());
return true;
case BinaryTypeCode.StringInternFirstMedium:
propInfo.SetValue(target, ReadAndRegisterInternedStringMedium(context));
propInfo.SetValue(target, context.ReadAndRegisterInternedStringMedium());
return true;
}
break;
@ -1239,55 +1239,10 @@ public static partial class AcBinaryDeserializer
return context.ReadAsciiBytesAsString(length);
}
/// <summary>
/// H2Q6 StringInternFirstSmall reader: wire <c>[cacheIdx:VarUInt][charLen:8][utf8Len:8][bytes]</c>
/// after the marker has been consumed. Registers the decoded string in the intern cache.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadAndRegisterInternedStringSmall<TInput>(BinaryDeserializationContext<TInput> context)
where TInput : struct, IBinaryInputBase
{
// First interning marker proves payload uses string interning → plain String entries
// appear only once, so _stringCache would never hit on them.
context.DisableStringCaching();
var cacheIndex = (int)context.ReadVarUInt();
var header = context.ReadTwoBytesUnsafe();
var charLength = (byte)header;
var byteLength = (byte)(header >> 8);
if (byteLength == 0)
{
context.RegisterInternedValueAt(cacheIndex, string.Empty);
return string.Empty;
}
var str = context.ReadStringUtf8WithCharLen(charLength, byteLength);
context.RegisterInternedValueAt(cacheIndex, str);
return str;
}
/// <summary>
/// H2Q6 StringInternFirstMedium reader: wire <c>[cacheIdx:VarUInt][charLen:16 LE][utf8Len:16 LE][bytes]</c>.
/// Registers the decoded string in the intern cache. (Big tier never engages on the interning path —
/// see <see cref="BinaryTypeCode"/> H2Q6 layout comment.)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadAndRegisterInternedStringMedium<TInput>(BinaryDeserializationContext<TInput> context)
where TInput : struct, IBinaryInputBase
{
context.DisableStringCaching();
var cacheIndex = (int)context.ReadVarUInt();
// Pack charLen:16 | utf8Len:16 read in a single uint load
var packed = context.ReadUInt32Unsafe();
var charLength = (ushort)packed;
var byteLength = (ushort)(packed >> 16);
if (byteLength == 0)
{
context.RegisterInternedValueAt(cacheIndex, string.Empty);
return string.Empty;
}
var str = context.ReadStringUtf8WithCharLen(charLength, byteLength);
context.RegisterInternedValueAt(cacheIndex, str);
return str;
}
// ReadAndRegisterInternedStringSmall / Medium moved to BinaryDeserializationContext as instance
// methods — single source of wire-decode shared by TypeReaderTable dispatch, PopulateProperty
// cross-type path, and the SGen-emitted string-property switch. See
// `BinaryDeserializationContext.Read.cs` for the implementations.
///// <summary>
///// Read a string and register it in the intern table for future references.

File diff suppressed because one or more lines are too long

28
nuget.config Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Repo-scoped NuGet source declaration. Encodes the workspace-dependency contract from
`.github/copilot-instructions.md` (`@repo.own-dep-repos = []`) and `CONSUMERS.md`
(AyCode.Core is Layer 0 — no upstream repos):
AyCode.Core has NO external repo dependencies. Therefore restore MUST NOT depend
on any machine-level or user-level NuGet feed pointing at a sibling repo's build
output (e.g. Mango.Nop.Core/bin/...). Such feeds, when configured globally on a
developer machine for another repo's workflow, would otherwise be probed during
`dotnet restore` for THIS repo too — and produce NU1301 errors when their paths
don't exist, even though no package from them is actually required.
The `<clear />` element explicitly drops all parent NuGet configurations
(machine-level + user-level + any walk-up Directory-level config) for this repo's
scope. Only `nuget.org` remains, matching the public packages declared in the
csproj files (AutoMapper, MessagePack, MemoryPack, Newtonsoft.Json, Microsoft.*).
Determinism: every dev machine + CI agent resolves the same source set. Side
effect-free with respect to other repos sharing the same developer machine — their
own per-repo nuget.config (or lack thereof) is unaffected.
-->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>