175 KiB
AcBinarySerializer — TODO
This page covers planned work for the binary serializer core (format, SGen, options, deserialization context, buffer writer). Work specific to the streaming I/O layer (AsyncPipeReaderInput + AsyncPipeWriterOutput, multi-message wire framing, sliding-window buffer, producer-consumer synchronization) is tracked separately in BINARY_ASYNCPIPE_TODO.md.
Priority legend
- P0 blocker · P1 important · P2 nice-to-have · P3 idea
Archived entries: see
BINARY_TODO_2026_04.mdandBINARY_TODO_2026_05.md(year-month bucket archives per LLMP-DEC retention policy). Archive files are not auto-loaded — read on demand if relevant context is suspected (regression hint, supersession reference, ID lookup for archived entry).
ACCORE-BIN-T-V3P9: Prefix-tier VarUInt + String marker unification + FixStrCount constant
Status: Closed (2026-05-26) · Priority: P1 · Type: Performance + Wire format
Three coordinated changes (single wire-format-breaking iteration, no FormatVersion bump per user
decision — old payloads not deserializable with the new code):
Resolution (2026-05-26)
1. Prefix-tier VarUInt encoding (replaces LEB128). WriteVarUIntMultiByteUnsafe and ReadVarUInt
in AcBinarySerializer.BinarySerializationContext + AcBinaryDeserializer.BinaryDeserializationContext
BufferWriterBinaryOutputrewritten from LEB128 continuation-loop to UTF-8-style prefix-tier encoding. First-byte prefix bits (0xxxxxxx/10xxxxxx/110xxxxx/1110xxxx/1111xxxx) determine total size in O(1); remaining bytes are fixed-size little-endian. Wire-size identical to LEB128 across all 5 tiers (7/14/21/28/32 bit) — auto-inc IDs pay the same byte count. Decode wins: 1 if-ladder + 1 fix-sized load on 3+ byte tiers instead of N×ReadByte + N×shift + N×continuation-check. Encoder usesBitOperations.Log2+ singleUnsafe.WriteUnaligned<uint>store for the 2/3/4-byte tiers (branch count reduced from 4 to 1). SeeBINARY_FORMAT.mdVariable-Length Encoding section for the tier table.
2. String marker unification (StringLen8/16/32 → single String marker). The 3-marker
magnitude-tier dispatch (167 StringLen8 + 168 StringLen16 + 169 StringLen32) was redundant
once VarUInt itself became prefix-tier. Collapsed into a single String marker at 167 with VarUInt
charLength + slot. Slots 168, 169 freed for future marker allocation. Wire layout:
[String:1] [VarUInt(charLength - FixStrCount)] [excess slot:1|2|4] [UTF-8 bytes]. The slot-size
(1/2/4) is still derived from charLength via GetUniversalStringExcessSlotSize. BinaryTypeCode.cs,
WriteStringWithDispatch, ReadUniversalLongString*, TryReadStringProperty, AcBinaryDeserializer
reader registration / PopulateProperty switch / Skip path, and AcBinarySourceGenerator.GenReader
emit all updated. StringAscii alias deleted.
3. FixStrCount constant. New public const int FixStrCount = FixStrMaxLength + 1 in
BinaryTypeCode.cs. Single source of truth for the FixStr slot count (= 32), also the wire-format
offset for the long-form String marker's VarUInt charLength (wireLen = charLength - FixStrCount).
If the FixStr range ever expands (e.g. 32 → 64 slots), this constant updates the offset everywhere
consistently — both the writer (charLength - FixStrCount) and reader (wireLen + FixStrCount).
4. EnsureAvailable micro-optimization. Combined if (!TInput.IsTrustedSingleSegment && ...)
short-circuit (was two separate ifs). Single branch, better Tier-0 / cold-path / AOT codegen.
Hot-path JIT (Tier-1) was already CSE-equivalent.
5. ReadVarUInt incremental-byte slow path. The 4/5-byte tier slow path now accepts b0/b1/b2
as parameters (no re-read of bytes the inline fast path already consumed). Inline fast path covers
1/2/3-byte tiers (cross-segment safe via ReadByte() → EnsureAvailable(1) JIT-eliminate on
ArrayBinaryInput).
6. WriteStringWithDispatch if-cascade reorganization. Replaced 4 isFixStr ? ... : ...
ternaries with a single explicit if (isFixStr) { ... } else { ... } block. Each branch holds
its own constants (no cross-branch ternary CSE pressure on the JIT). Tier-0 / cold-path codegen
slightly cleaner; Tier-1 hot-path was already CSE-equivalent (bench-confirmed neutral, but smaller
IL — ?: in C# is 4 separate brfalse/brtrue instructions, not 1).
Acceptance criteria met
- ✅ Full solution build (
AyCode.Core.sln) — 0 errors. - ✅ Benchmark snapshot (Latin1Short, 2026-05-26 14:20): AcBinary vs MemoryPack Ser median −3.1%
(was −1.8% before), Ser geo −3.2% (was −2.5%). Per-cell improvements 0.3..2.2% on Ser,
2.2..3.7% on Deser. Bench file:
Test_Benchmark_Results/Benchmark/Console.FullBenchmark_Release_2026-05-26_14-20-29.LLM. - ✅ Doc-sync:
BINARY_FORMAT.mdVariable-Length Encoding + Strings + FixStr sections updated to reflect the new wire layout. Reserved-range table added (103..134, 168..169, 170..175 buckets).
Wire-format breaking note
This change set is wire-format breaking — payloads serialized by the pre-V3P9 code (LEB128 +
StringLen8/16/32) are NOT deserializable by the new code. Per user decision the FormatVersion
header byte was NOT bumped (silent breaking; AcBinary is consumer-private, no cross-deployment
compatibility surface). If future versioned compat is desired, a FormatVersion 1 → 2 bump would
be the conventional approach.
SignalR-protocol-layer follow-on
A separate closed entry ACCORE-SBP-T-W7K4
documents the SignalR-side parity fixes the V3P9 wire-format change exposed:
AcBinaryHubProtocol.ReadVarUInt(ref SequenceReader<byte>) standalone LEB128 → prefix-tier port,
plus a latent T = object generic-inference regression cluster surfaced by the V3P9 test breaks
(test count: 320 → 10, within baseline flaky variance).
ACCORE-BIN-T-N4P8: SGen reference-property null-check parity across all four emit branches
Status: Closed (2026-05-23) · Priority: P1 · Type: Bug fix
AcBinarySourceGenerator.GenWriter.cs had four reference-property emit branches with divergent null handling. EmitDirectObjectWrite (line ~828), EmitDirectCollectionWrite (line ~877), and the IsObjectDeclaredType branch (line ~295) unconditionally emitted if ({a} == null) PropertySkip; else …, with explicit comments acknowledging that runtime null is possible regardless of the nullable annotation. The fourth branch — the fallback WriteObjectGenerated call (line ~309) — was inconsistent: it null-checked only when p.IsNullable=true, leaving the else branch (non-nullable signature) without a null guard. Real-world impact: cross-assembly types without a parent-side generated writer (e.g. NopCommerce Customer declared as Customer Customer { get; set; } in FruitBank.Common.Dtos.OrderDto) fall onto the fallback branch with IsNullable=false; if the runtime value is null (EF lazy-load gap, projection omission, detached navigation property), the generated code emits AcBinarySerializer.WriteObjectGenerated(obj.Customer, …) which then Unsafe.As<T>(value) yields a null reference and the first property access NREs. The bug surfaced in SignalR chunked streaming: an OrderItemPallet → OrderItemDto → OrderDto → Customer graph with a null Customer produced a serialize-time NullReferenceException in Customer_GeneratedWriter.WriteProperties:23 (the first obj.Active read).
Resolution (2026-05-23)
Single-line semantic change in AcBinarySourceGenerator.GenWriter.cs: the else if (p.IsNullable) + else branches in the PropertyTypeKind.Complex fallback path (line ~309-315) collapsed into one always-null-check branch:
else
{
// Reference type properties can always be null at runtime regardless of nullable annotation
// (mirrors EmitDirectObjectWrite / EmitDirectCollectionWrite — runtime can violate the
// nullable-disabled contract via EF lazy-load, projection gaps, detached navigation properties).
sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);");
sb.AppendLine($"{i}else AcBinarySerializer.WriteObjectGenerated({a}, typeof({p.TypeNameForTypeof}), context);");
}
All four reference-property emit branches now share the same defensive null-check pattern.
Reader-side compatibility: no changes needed. EmitReadProperty (GenReader.cs ~137) already wraps every markered property in if (tc != BinaryTypeCode.PropertySkip) { … }, so the new marker is uniformly handled — the property stays at its default value (null for reference types).
Acceptance criteria met:
- ✅ Production stack-trace (
Customer_GeneratedWriter:23NRE in chunked send viaOrderItemPallet → OrderItemDto → OrderDto → Customer) no longer reproducible. - ✅ All four SGen reference-property emit branches now consistently null-check (parity verified by code inspection).
- ✅ Regression tests added in
AcBinarySerializerSGenNullComplexPropertyTests.cs: 8 test cases (2 methods × 4 DataRows = SGen/Reflection × FastMode/Default), covering both null-property and non-null-property paths with marker-byte verification (assertsBinaryTypeCode.PropertySkippresence in the wire-output for the null slot — confirms the fix took the PropertySkip path, not an unrelated null-safe code path). - 🟢 Discovery vector: the companion CHUNK_ABORT fault-isolation feature (
ACCORE-SBP-T-Q3M7) was developed in response to the production NRE, which in turn exposed this unprotected emit branch. With this fix, CHUNK_ABORT becomes a genuine safety-net for unrelated failures rather than a workaround for a self-inflicted serializer bug.
Follow-up (2026-05-23): Collection-ag parity fix
After the Complex-branch fix landed, a second production CHUNK_ABORT surfaced from the same call chain (OrderItemPallet → OrderItemDto → OrderDto → ...), but the stack-trace now pointed to a different property — OrderDto.OrderNotes (a List<Nop.Core.Domain.Orders.OrderNote> with cross-assembly element type, no [AcBinarySerializable]) — and the NRE happened inside a lambda_method (Expression.Compile-d runtime accessor) reached via AcBinarySerializer.WriteValueGenerated. Re-audit of GenWriter.cs revealed the exact same parity bug, but on the PropertyTypeKind.Collection branch (~line 321): the else if (p.IsNullable) … else … shape, where the else (non-nullable signature) emitted WriteValueGenerated({a}, …) without a null guard. Production OrderNotes was non-nullable in signature, null at runtime (NopCommerce convention: no initializer).
Audit completed across all four PropertyTypeKind emit branches in GenWriter.cs:
- ✅
PropertyTypeKind.Complex(line ~282) — fixed in the original N4P8 above. - ✅
PropertyTypeKind.Collection(line ~321) — fixed now:else if (p.IsNullable) … else …collapsed into one always-null-check branch; element-side fallback (WriteValueGenerated) now sees only non-null collections. - ✅
PropertyTypeKind.Dictionary(line ~333) — unconditionally routes throughEmitDirectDictionaryWrite(line ~1031), which already null-checks at line ~1037 with the same explicit comment. - ✅
default(EmitSkip) — value-type primitives, no null possible.
The companion WriteValueGenerated bridge in AcBinarySerializer.cs shares the non-null contract with WriteObjectGenerated; both assume the caller emitted a null-check. The four-branch parity is now complete and verified by code inspection.
Regression tests extended in AcBinarySerializerSGenNullComplexPropertyTests.cs with four additional methods covering both new PropertyTypeKinds:
Serialize_SGenCollectionPropertyNull_DoesNotThrow_AndRoundTripsAsNull+…NonNull_RoundTripsCorrectly— Collection-ag regression (the actual bug-fix coverage).Serialize_SGenDictionaryPropertyNull_DoesNotThrow_AndRoundTripsAsNull+…NonNull_RoundTripsCorrectly— Dictionary-ag pin (already null-safe at line ~1037, but previously had no regression test to lock that behaviour in against future refactors).
Each method uses the same 4-row (useSgen, fastMode) parameterization plus a marker-byte verification (Assert.IsTrue(Array.IndexOf(bytes, BinaryTypeCode.PropertySkip) >= 0, …)) on the null tests, confirming the writer took the PropertySkip path rather than an unrelated null-safe branch. Total coverage: 24 test cases (6 methods × 4 rows = Complex/Collection/Dictionary × null/non-null × SGen/Reflection × FastMode/Default). New models SGenNullCollectionParent and SGenNullDictionaryParent in SGenNullComplexPropertyModels.cs mirror the production shapes (non-nullable List<NonGeneratedComplexCustomer> / Dictionary<string, NonGeneratedComplexCustomer>).
ACCORE-BIN-T-P6M4: Universal hotpath optimization guardrails + follow-up backlog
Priority: P1 · Type: Performance
AcBinary is a universal serializer. Hotpath work must avoid benchmark-only overfitting.
For each performance TODO, validate on representative workload mixes (ASCII-heavy, mixed Latin, multi-byte UTF-8; small/medium/large/deep payloads) and evaluate throughput + latency + allocation + wire-size together.
Follow-up backlog (short):
- Split oversized hot methods into inline-friendly dispatcher + cold helpers (writer/reader/populate).
- Add direct fast branches for the most frequent markers before generic table-dispatch.
- Reduce repeated
EnsureAvailablechecks by grouping fixed-width reads under one bounds check. - Extend VarUInt fast-path coverage for common 3-byte cases on metadata/index/cache-id routes.
- Reorder populate/property-loop branches by runtime frequency (
PropertySkip/Null/primitive fast-setters first). - Minimize pool/clear overhead by avoiding unnecessary aggressive array clearing in hot lifecycle paths.
- Add early scan-pass short-circuit when options guarantee no ref/intern benefit.
ACCORE-BIN-T-K9M3: Hoist wire codec primitives to context instance methods (ser + deser, feature-aware SGen emit)
Priority: P2 · Type: Refactor + Performance · Related: ACCORE-BIN-T-P6M4 (hotpath guardrails), BINARY_ISSUES.md#accore-bin-i-t7k3 (polymorph compile-time guard), BINARY_SGEN_OPTIMIZATION.md (per-property emit-condensation companion brainstorming)
Motivation
Wire codec logic is currently triplicated:
- SGen-emit inlines marker decode/encode at every property emit site (
StringInternFirstSmall,Object/ObjectRefFirst/Null/ObjectRef/FixObj-slot dispatch, etc.). - Runtime
TypeReaderTabledispatches viastatic (ctx, _) => ReadXxx(ctx)lambdas to per-markerstatichelpers inAcBinaryDeserializer. - Cross-type populate (
PopulatePropertyfallback) repeats the same per-marker switch.
Result: bug-fix risk (three copies drift), ad-hoc divergence (the polymorph ObjectWithTypeName emit was missing on the SGen side for months — ACCORE-BIN-I-T7K3), larger generated assemblies, longer JIT time. A single instance method on the context is the natural single-source-of-truth for each wire primitive.
Pilot landed
ReadAndRegisterInternedStringSmall / Medium moved from static helpers on AcBinaryDeserializer to internal instance methods on BinaryDeserializationContext. All three call paths (TypeReaderTable lambdas, cross-type PopulateProperty switch, SGen-emit EmitReadProp case-body) now call context.ReadAndRegister...(). Generated case-body shrank from 12 lines to 3 per case — no perf regression ([AggressiveInlining] keeps the JIT/AOT inline footprint identical).
Scope — both ser and deser
Phase A — Decode primitives (deser context)
ReadStringSmall/Medium/Big(H2Q6 non-ASCII tiers).ReadPlainStringAscii(long ASCII tier).ReadObjectfamily — careful: this branches ontargetTypeand on the writer's runtime polymorphic slot table, both of which are call-site-context-specific. May not be a clean hoist; see "Caveat" below.
Phase B — Encode primitives (ser context)
WriteStringWithDispatch,WriteStringInternFirstWithDispatch— already partly on the context, audit completeness.- Marker-write helpers (
WriteObjectFullMarker*) — already on the context post-T7K3. - Audit: scan ser-side SGen-emit for any inline encode duplication that should move to the context.
Phase C — Feature-conditional SGen-emit
EmitReadProp (and the symmetric emit paths) may consult per-type Enable*Feature flags to omit case-branches for disabled features — but ONLY when the writer-side stripping is symmetric (the writer provably doesn't emit those markers either). Asymmetric stripping introduces a wire-misalignment bug; see RefAwareEmitPredicate in AcBinarySourceGenerator.Models.cs for the symmetric child-fact pattern.
| Disabled feature | Cases to skip in SGen reader emit | Writer-side symmetric? |
|---|---|---|
EnableInternStringFeature = false |
StringInterned, StringInternFirstSmall, StringInternFirstMedium |
Yes — writer's scan-pass skips string-property iteration; StringInternEligible forced false for this type |
EnableMetadataFeature = false |
ObjectWithMetadata, ObjectWithMetadataRefFirst |
Yes — writer's WriteObject skips the metadata marker branch when the type's EnableMetadataFeature=false |
EnablePolymorphDetectFeature = false |
(no extra emit — ACBIN002 compile-error already guards object property declarations) |
Yes — guarded at compile time |
NOT applicable here: EnableRefHandlingFeature = false. The runtime writer emits ObjectRef / ObjectRefFirst markers based on child-type metadata (UseTypeReferenceHandling(childMetadata)), NOT the parent type's per-type flag. The parent-flag governs ONLY the parent's self-tracking scan-emit. Reader-side ObjectRef* case-emit on child properties of this type MUST stay present — gated on the child compile-time fact (ChildNeedsRefScan / ElementNeedsRefScan / DictValueNeedsRefScan) via RefAwareEmitPredicate.
After Phase C (revised scope): leaner generated code per opt-out type for the symmetric flags above; wire-misuse on those (e.g. mixed writer/reader feature configurations) surfaces as explicit fail-fast in the default switch arm — same philosophy as ACBIN002.
Perf guardrails (NON-NEGOTIABLE)
The hoisting MUST NOT regress SGen hot-path performance. The pilot iteration was a net positive (less IL → faster cold-start JIT, smaller native code, identical inline body); this property has to hold for every subsequent hoist.
Rules of thumb:
- Every hoisted method MUST have
[MethodImpl(MethodImplOptions.AggressiveInlining)]. - Body must stay small (≤ ~30 IL instructions after compile) so the JIT/AOT actually inlines — verify via
dotnet jit-dasmspot-check on representative callers. - Single-purpose; no
if-branches across distinct call-site contexts (those stay inline at the call site where the context-specific constants are visible). - Benchmark verification before/after each hoist (
Console.FullBenchmark).
JIT / NativeAOT outlook
Modern .NET JIT (≥7) and NativeAOT both honour AggressiveInlining for small bodies → the hoisted methods inline back into the caller at compile time → identical native code to the previous inline-emit. The IL is smaller (less SGen-emit per file), which gives:
- Faster cold-start JIT (less IL to translate on first call per type).
- Smaller assemblies on disk (NativeAOT publish size shrinks).
- Smaller i-cache footprint per active hot type (since SGen-emit no longer balloons per property).
The generic <TInput> specialization remains: each ArrayBinaryInput / SequenceBinaryInput / AsyncPipeReaderInput still gets its own native body (TInput.IsTrustedSingleSegment constant-folds per specialization), so no overhead vs. the current state.
NativeAOT additionally prefers small, single-purpose methods: register-allocation (LSRA) is more effective, peephole / loop-unroll / dead-code passes run faster per method, and the published native image is denser. The previous "giant SGen-emitted ReadProperties body" pattern was actively hostile to AOT in this respect.
Caveat — where NOT to hoist
Not every inline emit is a candidate. If the inline body carries compile-time constants (typeof(TFoo) literal, direct Instance.ReadProperties call on a concrete generated reader class, nameof(prop) constant), hoisting forces those into runtime parameters: constant-folding opportunity lost AND a direct call may become virtual via interface dispatch. The Complex property dispatch (Object → new T + ReadProperties direct call) is in this category and should stay inline at the SGen emit site.
Decision per primitive: can it be expressed as a context method that takes only wire-bytes-relevant inputs (no targetType literal, no per-property setter callback)? If yes → hoist. If no → keep inline.
Acceptance
- Phase A: all shared decode primitives reachable as instance methods on
BinaryDeserializationContext. TypeReaderTable + cross-type populate + SGen-emit all call them. SGen-generated case-body for each affected marker is ≤ 3 lines. - Phase B: ser-side audit complete; any encode duplication closed by hoist or explicit "keep inline — see caveat" note in the SGen comment.
- Phase C: SGen-emit reader honours
Enable*Featureflags. Verified by spot-checking generated*.g.csfiles: anEnableInternStringFeature=falsetype's reader does NOT containStringInternFirstSmall/Medium/StringInternedcases. - Per-phase benchmark run (
Console.FullBenchmark) confirms no hot-path regression (within noise floor).
ACCORE-BIN-T-Q2N7: Re-evaluate DiscountProductMapping SGen exclusion
Priority: P3 · Type: Investigation · Related: BINARY_ISSUES.md#accore-bin-i-f1w8
Investigate whether the new int Id shadowing pattern can be handled by SGen (via base-class introspection, property-setter lookup on the base) to eliminate the runtime compiled-expression fallback for this entity class.
ACCORE-BIN-T-W9F1: Generate BinarySerializeTypeMetadata / BinaryDeserializeTypeMetadata at compile time
Priority: P1 · Type: Performance · Related: BINARY_ISSUES.md#accore-bin-i-n6q3
Eliminate the dominant first-call cost (reflection + Expression.Compile in metadata ctor) for SGen types by emitting pre-built metadata from the source generator.
Design outline:
TypeMetadataBase/BinarySerializeTypeMetadata/BinaryDeserializeTypeMetadataget a second constructor that accepts pre-computed values (hashes,MinWriteSize,ComplexPropertyCount, flags,IsIId,IdAccessorType, etc.). No reflection executes in this ctor.- Source generator keeps its existing
s_typeNameHash/s_propertyHashesstatic fields (hot-path access stays static, zero indirection) and passes the same references to the metadata — single source of truth, no duplicate computation. ModuleInitregisters both the writer/reader and the pre-built metadata into aGeneratedMetadataRegistry.GetWrapperSlowconsults this registry first, falling back to the reflection-basedMetadataFactoryfor runtime-only types.- Lazy
RuntimeInit()pattern forExpression.Compileproperty accessors:TypeMetadataBasegetsvolatile bool _runtimeInitialized+internal void RuntimeInit()(idempotent, no lock needed).GetWrapperSlowcallsmetadata.RuntimeInit()only whenwrapper.GeneratedWriter == null || !Options.UseGeneratedCode— SGen types skip it entirely (they never touch runtime accessors on their own metadata; non-SGen child types have their own metadata and run the factory path normally).- Hybrid mode stays correct: an SGen type on the SGen path never uses its own property accessors; a non-SGen child type's metadata runs the reflection ctor as today.
volatileguards the flag; multiple contexts may race intoRuntimeInit, second run is a no-op.
Thread safety: GlobalMetadataCache is ConcurrentDictionary; generated metadata is registered once at ModuleInit; wrapper construction is per-context and unchanged.
Acceptance:
- Cold benchmark: first
Serialize<T>of a fresh SGen type shows no reflection /Expression.Compileon the call stack. - Runtime fallback (
UseGeneratedCode=false) still produces identical wire output and uses the full metadata accessors. - Deserialize side has parity (same approach for
BinaryDeserializeTypeMetadata). - Existing tests pass; wire format unchanged.
ACCORE-BIN-T-T5J8: JIT Tier 1 warmup for generated hot methods
Priority: P2 · Type: Performance · Related: BINARY_ISSUES.md#accore-bin-i-n6q3
After ACCORE-BIN-T-W9F1 lands, JIT of generated WriteProperties / ScanObject / ScanForDuplicates becomes the dominant residual first-call cost for SGen types. Options to evaluate (benchmark before committing):
[MethodImpl(MethodImplOptions.AggressiveOptimization)]on the generated hot methods — skips Tier 0, compiles directly at Tier 1. Simple generator change. Trade-off: larger one-time JIT cost in exchange for eliminating the Tier 0→1 recompile step.- Background prewarm from
ModuleInit:Task.Run(() => RuntimeHelpers.PrepareMethod(handle))for each registered writer/reader method. Parallelizes JIT with app startup. Keep it opt-in (option flag) to avoid surprising consumers with extra startup threads. - ReadyToRun (R2R) in consuming projects' publish config — pre-compiles IL to native at publish time. External to SGen, complementary. Document as a recommended publish setting.
- Code chunking (split generated methods exceeding a property threshold into sub-methods, e.g.
WriteProperties_Part1/_Part2) — measure first. Only beneficial for unusually large types (20+ properties / nested collections). Call overhead can offset gains; JIT inliner may already handle reasonably-sized methods well. try/finallyaudit on hot path — On .NET 9 (project's minimum target), JIT silently refuses to inline any method containing an EH region (AggressiveInliningis ignored). [.NET 10 partially lifts this for same-module try-finally — seedotnet/runtime#112998, merged 2025-03-20 — butcatch, cross-module, and P/Invoke-stub cases stay blocked. Until project's minimum runtime moves to .NET 10, treat EH as an absolute inlining barrier; even after the upgrade, several sub-cases keep the rule.] Audit scope:- Hand-written bridges:
WriteValueGenerated/WriteObjectGenerated/WriteStringGenerated/ScanValueGeneratedand any helper called from generatedWritePropertiesfor accidentaltry/finally/usingblocks. - SGen output template (
AcBinarySourceGenerator.cs): generatedWriteProperties/ScanObject/ScanForDuplicates/ReadObject/ReadPropertiesMUST stay straight-line. Future feature additions ([CustomSerializer] / [CustomDeserializer] hooks,OnSerializing/OnDeserializedcallbacks, validation attributes, rented-bufferusingblocks) are tempting candidates fortry/catch/finally— emit them in separate cold helpers, never inline into the generated hot method. A single accidentaltryblock inWritePropertiesmakes the whole generated method non-inlinable, killing the SGen Root Fast Path benefit. - Resource cleanup (Pool/ArrayPool/Dispose) belongs in
Serialize<T>entry-frame only, not in per-property helpers or generated hot methods. SeeBINARY_IMPLEMENTATION.mdRule #3 (Inlining barriers) andBINARY_SGEN.md(SGen Output Constraints).
- Hand-written bridges:
stackallocsize discipline on hot path — On .NET 9, methods containinglocalloc(any C#stackalloc) historically blocked inlining. Modern .NET allows inlining only for fixed-sizestackalloc≤ 32 bytes outside loops (seedotnet/runtime#7113) — anything larger or loop-nested still blocks. Our typical scratch-buffer patterns (UTF-8 encoding scratch, ArrayPool fallbacks) sit far above 32 bytes (256+), so any helper containing such astackallocis non-inlinable. Combined withtry/finallyforArrayPool.Returncleanup, the method is doubly non-inlinable on .NET 9. Plan accordingly: keepstackalloc-using helpers as deliberate cold call-frames, not asAggressiveInliningcandidates.- Native AOT — out of scope for this TODO; separate architectural decision with deployment-model implications.
Acceptance:
- Benchmark a realistic entity graph (≥ 3 referenced child types) and show first-call time within ~10% of steady-state after ACCORE-BIN-T-W9F1 + chosen mitigation(s).
- Document which combination is recommended for SignalR hot-path workloads vs. batch serialization.
ACCORE-BIN-T-Z3K8: Replace IId<T> interface dependency with convention/attribute-based Id detection
Priority: P1 · Type: Refactor
The binary serializer currently detects Id-tracking properties via the IId<T> interface (AyCode.Interfaces). This couples the serializer to a framework-specific abstraction and forces consumer types to implement the interface for tracking participation. Move to a POCO-friendly detection scheme:
IdDetectionMode.Convention(default) — convention-based; any property namedIdis treated as the tracking key. Zero-friction onboarding.IdDetectionMode.Attribute— explicit; only properties marked with a serializer-native[Id](or similar) attribute are tracked.[IgnoreId]attribute — escape hatch inConventionmode to exclude an Id-named property from tracking when the developer wants explicit opt-out.
Implicit contract for Convention mode: within a single class, the Id property must be type-level unique. Whether it semantically represents a primary key or a sequence number is irrelevant — the tracker keys by (Type, Id), so per-type uniqueness is the only requirement. Violating this invariant typically signals a domain-modelling problem, not a serializer bug. Design rationale discussed in conversation 2026-04-27.
Acceptance:
- Binary serializer no longer references
IId<T>in any execution path (no interface checks, nowhere T : IId<TKey>constraints in the serializer surface). - Wire format unchanged.
- Existing consumers using
IId<T>-implementing types still work transparently inConventionmode (theirIdproperty is detected via convention). - New consumers can use plain POCOs with no
AyCode.Interfacesdependency. IdDetectionModeexposed onAcBinaryOptions(or successor options class post-rebrand).- Default mode =
Convention.
ACCORE-BIN-T-N7V1: Replace [JsonIgnore] dependency with serializer-native ignore attribute
Priority: P2 · Type: Refactor
Property exclusion from binary serialization currently relies on [JsonIgnore] (Newtonsoft.Json). This couples the binary serializer to a third-party JSON library's attribute and is conceptually wrong — a binary serializer should not consult a JSON-specific marker for its exclusion semantics.
Define a serializer-native ignore attribute (working name [BinaryIgnore]; final name TBD pending broader rebrand). For backward compatibility during transition, also continue recognizing [JsonIgnore] with a deprecation note.
Possible cross-cutting consideration: if Toon and other future serializers also need property-exclusion, a single shared attribute (e.g., [SerializerIgnore] in a common abstractions package) may be cleaner than per-serializer attributes. Decide before naming finalizes — this may belong in XCUT_TODO.md rather than purely BINARY scope.
Acceptance:
- Native ignore attribute defined in the binary serializer's namespace (or shared abstractions package, pending the cross-cutting decision above).
- Both native attribute and
[JsonIgnore]recognized during a transitional period; native attribute takes precedence on conflict. [JsonIgnore]recognition flagged for removal in a future major version (track in a follow-up cleanup TODO once consumer projects have migrated).- No new code dependency on Newtonsoft.Json for property-exclusion logic.
ACCORE-BIN-T-Y6R2: Implement projection serialization phase 1 (runtime path)
Priority: P1 · Type: Feature · Related: ../adr/0001-binary-projection-serialization.md (canonical)
Implement the phase 1 runtime path of source→target projection serialization per ADR 0001. See the ADR for full context, decision rationale, alternatives, consequences, and acceptance criteria.
Sibling rebrand-prep TODOs: ACCORE-BIN-T-Z3K8 (IId migration), ACCORE-BIN-T-N7V1 (JsonIgnore replacement).
ACCORE-BIN-T-K3W7: Rename BufferWriterChunkSize to reflect actual semantics
Priority: P3 · Type: Refactor · Breaking: Yes (public option API) · Streaming impact: see BINARY_ASYNCPIPE_TODO.md for the streaming-side companion considerations (chunk-on-wire vs internal-buffer semantics)
The property name BufferWriterChunkSize is misleading: across the three output paths it does NOT consistently represent a "chunk".
| Output path | What BufferWriterChunkSize actually controls |
Wire-format chunk? |
|---|---|---|
ArrayBinaryOutput (Byte[] API) |
Initial buffer capacity of the internal byte[] |
No |
BufferWriterBinaryOutput (IBufferWriter overload) |
Internal buffer size — how much data accumulates before Advance() + new GetMemory() on the underlying writer |
No |
AsyncPipeWriterOutput (streaming) |
Both internal buffer and wire-format chunk frame size for chunked framing | Yes (only here) |
Receive side (AsyncPipeReaderInput) |
Initial receive buffer = BufferWriterChunkSize × 2 |
No (just sizing hint) |
Only the streaming AsyncPipeWriterOutput path has a wire-format "chunk" concept (chunked framing for length-prefixed segments). On the other 75% of paths the property name reads as if the serializer were segmenting the payload, which is not what happens.
Possible directions (decide before implementing):
- Single rename, semantic-neutral —
BufferWriterChunkSize→BufferWriterBufferSizeorBufferWriterPageSize. Minimal API surface change, single-property semantics preserved. Downside: still slightly off for the streaming path where there IS chunked framing. - Two-property split —
InternalBufferSize(universal: how much data accumulates before Advance/Grow) +StreamingChunkSize(only meaningful forAsyncPipeWriterOutput; separate knob, defaults toInternalBufferSize). Cleanest semantics, most ceremony, slightly more options to document. - Single rename, streaming-honest — Keep as
BufferWriterChunkSizebut document explicitly that on non-streaming paths the value is repurposed as buffer size. Cheapest change (docs only). Downside: doesn't fix the underlying confusion the field name causes.
Pick one before touching code. Option 2 is the most correct but adds API surface; Option 1 is the pragmatic middle.
Affected callers / docs to update on rename:
AcBinarySerializerOptions.cs(definition)AcBinarySerializer.cs× 3 sites (ArrayBinaryOutputctor,BufferWriterBinaryOutputctor,AsyncPipeWriterOutputctor)AcBinaryDeserializer.cs× 1 site (receive-side initial capacity derivation)AsyncPipeReaderInput.cs— XML doc cross-refsBINARY_WRITERS.md,BINARY_TODO.md(this entry),BINARY_ISSUES.md(line 151 — already listsBufferWriterChunkSizeamong the struct-mutation issue's affected setters)- Consumer-side:
AyCode.Services/SignalRs/AcBinaryHubProtocol.csctor mutates_options.BufferWriterChunkSize = options.BufferSize;— seeBINARY_ISSUES.md#accore-bin-i-...(struct-mutation context). Coordinate the rename with the struct-mutation fix to avoid two cross-cutting churn waves on the same property.
Acceptance:
- Property renamed (or split) per the chosen direction; all internal references updated.
- XML docs reflect the actual semantics on each output path (initial capacity / advance threshold / chunk frame size — whichever applies).
- Consumer-side usage in
AcBinaryHubProtocolupdated; if Option 2 is chosen, the protocol usesStreamingChunkSize(the streaming knob), not the universal one. - Wire format unchanged. Default values unchanged (65535 / equivalent).
- Migration note in CHANGELOG / release notes since this is a breaking change to
AcBinarySerializerOptions.
ACCORE-BIN-T-M4D2: Add ReadOnlyMemory<byte> / Memory<byte> deserialize overloads
Priority: P3 · Type: Feature
The public AcBinaryDeserializer.Deserialize surface accepts byte[] (with optional offset/length) and ReadOnlySequence<byte>, but not ReadOnlyMemory<byte> / Memory<byte>. Consumers that hold a ReadOnlyMemory<byte> (cached payloads, message-broker frames, in-memory pipe slices) must call .ToArray() to round-trip through byte[] — unnecessary copy + GC alloc.
Implementation:
Deserialize<T>(ReadOnlyMemory<byte> data, AcBinarySerializerOptions options)and the non-genericType-based variant.- Body:
MemoryMarshal.TryGetArray(data, out var seg)→ array-backed path delegates toDeserialize<T>(seg.Array!, seg.Offset, seg.Count, options)(zero-copy). Non-array-backed fallback (rare — customMemoryManager<T>with native memory) copies into a pooledbyte[]. Memory<byte>overload trivially delegates to theReadOnlyMemory<byte>one (Memory<byte>is implicitly convertible).- No new input-strategy struct needed — reuses existing
ArrayBinaryInput.
Acceptance:
- Both overloads compile and pass round-trip tests against
byte[]-equivalent input. - Array-backed path measurably zero-alloc (BenchmarkDotNet allocation diagnoser).
- Non-array-backed path documented as fallback (separate
using var pooled = MemoryPool<byte>.Shared.Rent(...)style copy). - API doc-strings cross-reference the existing
byte[]andReadOnlySequence<byte>overloads.
ACCORE-BIN-T-S7X3: Add ReadOnlySpan<byte> deserialize overload
Priority: P2 · Type: Feature · Related: ACCORE-BIN-T-M4D2
The MemoryPack-style Deserialize<T>(ReadOnlySpan<byte>) API enables direct deserialization from stack-allocated buffers (stackalloc byte[256]), pinned native memory (fixed blocks), and ReadOnlyMemory<byte>.Span slices without round-tripping through a heap-allocated byte[]. The current AcBinary surface lacks this entry point.
Design tension: the existing IBinaryInputBase.Initialize(out byte[] buffer, ...) contract returns a byte[] — a ReadOnlySpan<byte> cannot be stored in a regular struct field, only in a ref struct field. Two implementation paths to evaluate:
ref struct SpanBinaryInput+ interface bump to supportref byte buffer/int lengthfields. Pure zero-copy from any span. Cost:BinaryDeserializationContext<TInput>andIBinaryInputBaseneed a parallel ref-struct-friendly track (the existing pooled context cannot hold aref struct). Major surgery on the deser core.MemoryMarshal.CreateReadOnlySpanFromNullTerminated-style hack — acceptReadOnlySpan<byte>, useUnsafe.AsRef/MemoryMarshal.GetReferenceto obtain aref byte, then copy into a pooledbyte[]before deserialization. Not zero-copy, defeats the purpose. Reject.- Pinned-buffer trampoline — accept
ReadOnlySpan<byte>, allocate aMemory<byte>view via aMemoryManager<byte>-like wrapper, delegate toReadOnlyMemory<byte>overload. Awkward, allocations per call. Reject.
Recommendation: option (1) is the only correct path, but it's a substantial refactor — measure first whether real consumer demand justifies the surgery. The current byte[]-based pool-pattern outperforms MemoryPack on the dominant use-cases per existing benchmarks; this overload addresses an API-surface gap, not a perf gap.
Acceptance:
Deserialize<T>(ReadOnlySpan<byte> data, AcBinarySerializerOptions options)compiles and round-trips againstbyte[]-equivalent input.- Zero-alloc path verified for
stackalloc-source spans (BenchmarkDotNet allocation diagnoser). IBinaryInputBase(or successor interface) refactor preserves backward compatibility for existingArrayBinaryInput/SequenceBinaryInput/AsyncPipeReaderInputAdapterconsumers.- Doc-strings cross-reference the
byte[]/ReadOnlyMemory<byte>(ACCORE-BIN-T-M4D2) /ReadOnlySequence<byte>overloads with use-case guidance.
ACCORE-BIN-T-T8K3: Add SerializeAsync(Stream, T) async overloads with mode-driven output strategy
Priority: P1 · Type: Feature · Related: ACCORE-BIN-T-N9G6 (Type-based coordination)
The mainstream serializer ecosystem (System.Text.Json, MessagePack, Newtonsoft.Json, MemoryPack) all expose SerializeAsync(Stream, T) as a primary entry point — async file I/O, network response body, log streaming. AcBinary's public API surface MUST include this overload regardless of what we do internally; consumers expect a Stream parameter and don't navigate PipeWriter.Create(stream) workarounds. Market-entry-blocking otherwise.
Mode-driven output strategy — three lanes for three workload shapes
AcBinary already models the three output strategies in BinaryProtocolMode (AyCode.Services/SignalRs/BinaryProtocolMode.cs) for the SignalR side. The same three-lane shape applies to the public SerializeAsync(Stream) API. Promote the concept to AcBinary core scope (e.g. AcBinaryOutputMode in AyCode.Core/Serializers/Binaries/) and let the SignalR BinaryProtocolMode either alias it or migrate to it. Migration timing: the existing BinaryProtocolMode keeps shipping until the new public API is stabilized; both names live for one major version, then BinaryProtocolMode becomes a using-alias.
| Mode | Output strategy | Peak memory | Pipeline parallelism | Use when |
|---|---|---|---|---|
Bytes (default) |
Serialize(T) → byte[] + stream.WriteAsync(bytes) |
Full payload in byte[] (pooled) |
No | Typical payloads (<10 MB), throughput-focus |
Segment |
BufferWriterBinaryOutput → PipeWriter, single closing flush |
PipeWriter pause-threshold-bounded (~64 KB Kestrel default) | No | Mid-size payloads, zero-copy desired |
AsyncSegment |
SerializeChunked(PipeWriter), per-chunk async flush |
Chunk-size-bounded (~8 KB at default BufferWriterChunkSize) |
Yes (on parallel-capable PipeWriter — Kestrel / Pipe) |
Very large payloads (>10 MB), memory-tight hosts, parallel-capable transport |
Honest performance positioning vs. MemoryPack — three real axes
MemoryPack's SerializeAsync(Stream) is pseudo-streaming — serializes the entire payload into a pool-allocated linked-list buffer first (ReusableLinkedArrayBufferWriter), then writes the completed buffer to the stream in a single closing fence. Peak memory ≈ payload size; no pipeline parallelism. AcBinary's Bytes mode is architecturally similar (single pooled contiguous byte[] vs. MemoryPack's linked-list) — comparable peak-memory cost, often faster on the wire due to one contiguous WriteAsync call.
AcBinary's AsyncSegment mode is architecturally different in three real ways MemoryPack cannot match:
| Axis | Bytes mode (default) |
AsyncSegment mode |
MemoryPack SerializeAsync |
|---|---|---|---|
| Heap allocation per call | Pooled byte[] rent (peak ≈ payload size) |
Truly zero — ArrayPool + pooled context + MemoryMarshal.TryGetArray direct-buffer-write into the transport's own byte[] |
Pool-allocated linked-list buffer per call (peak ≈ payload size) |
| Peak managed memory | ≈ payload size | ≈ chunk size (BufferWriterChunkSize, e.g. 4-8 KB) |
≈ payload size |
| GC pressure | Touches GC pool on every call | Never touches GC for the serialize itself | Touches GC pool on every call |
| Pipeline parallelism | No | Yes on parallel-capable PipeWriter (Kestrel transport, new Pipe()) |
No |
| GB-scale payload | OOM risk on memory-tight hosts | Works | OOM risk |
The AsyncSegment zero-alloc claim is literal, not "almost zero": AsyncPipeWriterOutput.AcquireChunk calls _pipeWriter.GetMemory(chunkSize) and uses MemoryMarshal.TryGetArray(memory, out segment) to obtain the transport's own internal byte[] — the serializer writes directly into it. With chunkSize aligned to the transport's internal buffer (e.g. NamedPipe-server pipe-buffer-size), one chunk is one kernel-level transfer; no managed-side double-fragmentation.
Throughput nuance — AsyncSegment cost on Stream-backed transports
AsyncSegment IS slightly slower than Bytes on StreamPipeWriter-backed transports (NamedPipe / FileStream / NetworkStream), but not for the reason that initially seems obvious:
- The cost is NOT "managed-side double-fragmentation on top of OS-level fragmentation" — that's not what happens.
MemoryMarshal.TryGetArrayzero-copy direct-buffer-writes mean the managed chunking is the same chunking the kernel does anyway, not redundant. - The cost IS the per-chunk async-await round-trip (
SyncAwaitFlush(_lastFlush)blocks until the kernel acknowledges the write), forced sequential by theStreamPipeWriter._tailMemoryreset race (ACCORE-BIN-I-...). N async cycles vs 1 inBytesmode. - Empirically the gap is roughly 1.2-1.5x on NamedPipe — not 2-5x. The dominant cost on these transports is the transport itself (Windows IRP / Linux FIFO syscall overhead), independent of the serializer mode.
When AsyncSegment wins outright:
- GC-sensitive hot-paths (server hubs, real-time game tick loops, mobile UI thread, embedded targets): zero-alloc + zero-GC-pressure beats a 1.2x throughput edge every time.
- Memory-tight hosts (mobile, WASM, container-trimmed, embedded): chunk-bounded peak memory is the only option.
- GB-scale payloads:
BytesOOMs;AsyncSegmentworks. - Kestrel transport / parallel-capable
Pipe: pipeline parallelism makesAsyncSegmentfaster thanBytesfor medium-to-large payloads.
When Bytes wins outright:
- Tipikus NuGet workload (small-to-medium payload, throughput priority, GC-tolerant): one async cycle vs N is the simpler, faster path.
MemoryStream(in-memory): one largebyte[]copy decisively beats N managed chunks.
Marketing claim — three-way honest comparison
"AcBinary offers a real choice.
Bytesmode for typical throughput-priority workloads (matches MemoryPack's pseudo-streaming, often faster on the wire).AsyncSegmentmode for the workloads MemoryPack cannot serve: zero-alloc serialize for GC-sensitive hot-paths, chunk-bounded peak memory for tight-budget hosts, GB-scale payloads, and pipeline parallelism on parallel-capable transports. You pick the mode; MemoryPack picks for you."
This is honest — does not overclaim universal speed, does not hide the small AsyncSegment cost on Stream-backed transports, AND clearly surfaces the three differentiator axes (alloc / memory / parallelism) where AcBinary architecturally beats MemoryPack.
Implementation outline:
- New enum
AcBinaryOutputMode { Bytes = 0, Segment = 1, AsyncSegment = 2 }inAyCode.Core/Serializers/Binaries/. DefaultBytes. - New mode field on
AcBinarySerializerOptions:AcBinaryOutputMode OutputMode { get; set; } = AcBinaryOutputMode.Bytes;. (Note: subject toACCORE-BIN-I-L8N5thread-safety treatment — defensive copy / immutable refactor coordination.) public static ValueTask SerializeAsync<T>(T value, Stream stream, AcBinarySerializerOptions? options = null, bool leaveOpen = false, CancellationToken ct = default):- Switch on
options.OutputMode:Bytes→var bytes = Serialize(value, options); await stream.WriteAsync(bytes, ct); ArrayPool.Return(bytes);Segment→var pw = PipeWriter.Create(stream, new(leaveOpen: leaveOpen)); Serialize(value, pw, options); await pw.CompleteAsync();AsyncSegment→var pw = PipeWriter.Create(stream, new(leaveOpen: leaveOpen)); SerializeChunked(value, pw, options); await pw.CompleteAsync();
- Switch on
public static ValueTask SerializeAsync(object? value, Type type, Stream stream, ...)— non-generic, same dispatch (coordinated withACCORE-BIN-T-N9G6).leaveOpenparameter standard for stream-async serializers (System.Text.Json, MessagePack convention).- The
Bytesmode uses a pooledbyte[]fromArrayBinaryOutputto keep alloc cost amortized.
SignalR migration coordination: the existing BinaryProtocolMode enum (in AyCode.Services) keeps shipping unchanged until the new public API is stabilized. After stabilization, BinaryProtocolMode becomes a deprecated alias of AcBinaryOutputMode, eventually removed in a major-bump. No SignalR-side churn during this TODO's implementation.
Acceptance:
SerializeAsync<T>round-trips againstDeserialize<T>(byte[])viaMemoryStreamin all three modes.- Cancellation propagates correctly (
OperationCanceledExceptionon cancelled token mid-stream). - Throughput matrix benchmark: 4 transports (
MemoryStream,FileStream,NamedPipeStream,NetworkStream) × 3 modes × 3 payload sizes (small ~1 KB / medium ~100 KB / large ~10 MB). Results documented inTest_Benchmark_Results/Benchmark/SerializeAsync_Stream_Modes.LLM(or similar) and surfaced as a doc-string table for consumer guidance. - Memory-bounded benchmark: 100 MB payload to
FileStreaminAsyncSegmentmode → peak managed-heap delta ≤ 1 MB throughout. Same payload inBytesmode → peak ~100 MB (expected, documented). - API doc-string contains a "When to use which mode?" decision matrix; explicitly compares with MemoryPack's pseudo-streaming.
leaveOpenparameter behaves per the System.Text.Json / MessagePack convention across all three modes.
ACCORE-BIN-T-D7K4: Add DeserializeAsync(Stream, T) async overloads with mode-driven input strategy
Priority: P1 · Type: Feature · Related: ACCORE-BIN-T-T8K3 (companion write-side overload), ACCORE-BIN-T-N9G6 (non-generic Type-based dispatch)
Companion to T8K3 on the receive side. The mainstream serializer ecosystem (System.Text.Json, MessagePack, Newtonsoft.Json, MemoryPack) all expose DeserializeAsync<T>(Stream) — the symmetric counterpart of SerializeAsync(Stream, T). AcBinary's public API surface MUST include this overload for parity; consumers expect a Stream parameter for receive paths (file load, HTTP response body, network stream) and don't navigate PipeReader.Create(stream) workarounds. Market-entry-blocking otherwise.
Implementation: zero new IBinaryInputBase impl needed
The existing receive-side primitives cover the full strategy space via BCL PipeReader.Create(stream):
| Mode | Input strategy | Peak memory | Pipeline parallelism | Use when |
|---|---|---|---|---|
Bytes (default) |
await stream.CopyToAsync(MemoryStream) → Deserialize<T>(byte[]) (existing overload) |
Full payload as byte[] (pooled) |
No | Typical payloads (<10 MB), throughput-focus |
Segment |
await PipeReader.Create(stream).ReadAsync() → Deserialize<T>(ReadOnlySequence<byte>) (existing overload) |
PipeReader pause-threshold-bounded (~64 KB) | No | Mid-size payloads, no full byte[] alloc desired |
AsyncSegment |
AsyncPipeReaderInput + DrainFromAsync(PipeReader.Create(stream)) + Deserialize<T>(input) (existing overload) |
Chunk-size-bounded (~8 KB) | Yes (producer drain Task in parallel with deser Task) | Very large payloads (>10 MB), memory-tight hosts |
The AcBinaryOutputMode enum (introduced by T8K3) is symmetric — it controls deser-input strategy as well. The same enum value picks the matching read path. No new IBinaryInputBase implementation needed — the trio of existing inputs (ArrayBinaryInput, SequenceBinaryInput, AsyncPipeReaderInput) already cover all three modes; the new overload is a thin shim that wraps the Stream and routes to the right existing overload.
Public API shape
public static ValueTask<T?> DeserializeAsync<T>(
Stream stream,
AcBinarySerializerOptions? options = null,
bool leaveOpen = false,
CancellationToken ct = default);
// Non-generic Type-based variant (coordinated with N9G6):
public static ValueTask<object?> DeserializeAsync(
Stream stream,
Type targetType,
AcBinarySerializerOptions? options = null,
bool leaveOpen = false,
CancellationToken ct = default);
Implementation outline (per mode)
// Bytes mode (default — simplest path, sub-LOH-friendly fast path):
public static async ValueTask<T?> DeserializeAsync_Bytes<T>(Stream stream, ..., CancellationToken ct)
{
var rented = ArrayPool<byte>.Shared.Rent((int)Math.Min(stream.CanSeek ? stream.Length : 4096, int.MaxValue));
try
{
var totalRead = 0;
int read;
while ((read = await stream.ReadAsync(rented.AsMemory(totalRead), ct)) > 0)
{
totalRead += read;
if (totalRead == rented.Length) { /* grow rented */ }
}
return Deserialize<T>(rented, 0, totalRead, options);
}
finally { ArrayPool<byte>.Shared.Return(rented); }
}
// Segment mode (PipeReader.Create wrapping, then drain to ReadOnlySequence):
public static async ValueTask<T?> DeserializeAsync_Segment<T>(Stream stream, ..., CancellationToken ct)
{
var pipeReader = PipeReader.Create(stream, new(leaveOpen: leaveOpen));
var result = await pipeReader.ReadAtLeastAsync(int.MaxValue, ct); // drain whole stream
var seq = result.Buffer;
var obj = Deserialize<T>(seq, options);
pipeReader.AdvanceTo(seq.End);
await pipeReader.CompleteAsync();
return obj;
}
// AsyncSegment mode (chunked streaming pipeline, parallel drain + deser):
public static async ValueTask<T?> DeserializeAsync_AsyncSegment<T>(Stream stream, ..., CancellationToken ct)
{
using var input = new AsyncPipeReaderInput(options.BufferWriterChunkSize * 2, multiMessage: false);
var pipeReader = PipeReader.Create(stream, new(leaveOpen: leaveOpen));
var deserTask = Task.Run(() => Deserialize<T>(input, options), ct);
await input.DrainFromAsync(pipeReader, ct);
await pipeReader.CompleteAsync();
return await deserTask;
}
Honest performance positioning
Symmetric to T8K3's analysis:
Bytesmode: simplest, single contiguousbyte[](pooled) →Deserialize<T>(byte[]). Comparable to MemoryPack'sDeserializeAsync(which does similar full-buffer-then-deser). Best for typical workloads.Segmentmode: zero-copy from PipeReader's naturalReadOnlySequence<byte>— no extra byte[] allocation. Best for mid-size payloads where allocation matters but pipeline overlap doesn't.AsyncSegmentmode: producer-drain Task and consumer-deser Task in parallel viaAsyncPipeReaderInput. Wall-clock = max(network-drain, deser-CPU) + small overlap-cost. Best for large payloads + slow transports (network, mobile, satellite — where transit dominates and overlap pays).
Acceptance
DeserializeAsync<T>round-trips againstSerializeAsync(Stream, T)(T8K3) viaMemoryStreamin all three modes.- Cancellation propagates correctly (
OperationCanceledExceptionon cancelled token mid-stream); partial-buffer state cleaned up; pooled byte[] returned even on cancellation. - Throughput matrix benchmark (mirror of T8K3): 4 transports (
MemoryStream,FileStream,NamedPipeStream,NetworkStream) × 3 modes × 3 payload sizes. Results documented inTest_Benchmark_Results/Benchmark/DeserializeAsync_Stream_Modes.LLM. - Memory-bounded benchmark: 100 MB payload from
FileStreaminAsyncSegmentmode → peak managed-heap delta ≤ 1 MB throughout. Same payload inBytesmode → peak ~100 MB (expected, documented). - API doc-string contains a "When to use which mode?" decision matrix; cross-references T8K3's symmetric write-side guidance.
leaveOpenparameter behaves per the System.Text.Json / MessagePack convention across all three modes.
ACCORE-BIN-T-R4P2: Expose low-level ref Writer-style API for custom formatters
Priority: P3 · Type: Feature
The MemoryPack-style Serialize<T>(ref MemoryPackWriter writer, in T value) low-level API enables:
- Custom formatters that compose write primitives without the full Serialize entry-point overhead.
- Nested-into-existing-stream scenarios where the caller already owns a writer-style cursor.
- Test harnesses that exercise specific wire-format paths in isolation.
Today's BufferWriterBinaryOutput standalone-mode partly fills this gap — exposing WriteByte, WriteVarUInt, WriteStringUtf8, etc. — but it is not a ref struct, not a documented low-level public API for external custom formatters, and the relationship with BinarySerializationContext<TOutput> is unclear from the consumer's perspective.
Design tension (decide before implementing):
- Promote
BufferWriterBinaryOutputto documented public surface — add doc, examples, supported usage patterns. Cheapest, but the standalone-mode is currently a side-feature, not a primary API; documenting it commits to its current shape. - New
ref struct AcBinaryWriterwrapper aroundBufferWriterBinaryOutput(or a dedicated impl) — explicit "this is the low-level writer" signal. More API surface but clearer mental model. Aesthetic alignment with MemoryPack. - Skip entirely — the
IBufferWriter<byte>overload is already lower-level than most consumers need; custom formatters can write to anArrayBufferWriter<byte>and useIBufferWriter-style primitives. This is whatBufferWriterBinaryOutputalready does internally.
Recommendation: option 3 is honest — the existing IBufferWriter<byte> overload covers the use case, and adding a ref struct AcBinaryWriter is mostly aesthetic alignment with MemoryPack. Re-evaluate when there's a concrete custom-formatter request that the current API can't accommodate.
Acceptance (if implemented):
AcBinaryWriter ref struct(or equivalent) compiles, supports the same write primitives asBufferWriterBinaryOutputstandalone-mode.- At least one example custom formatter ships in tests (e.g., a
Vector3struct formatter). - Doc-string clearly distinguishes when to use the low-level writer vs. the high-level
Serialize<T>entry-point.
ACCORE-BIN-T-U6Y8: Attribute-driven polymorphism via [AcBinaryUnion] + SGen (opt-in, AOT-friendly)
Priority: P1 (if AOT target required) / P2 (non-AOT only) · Type: Feature
Related security work: the runtime polymorphism path (AQN-based, the default this TODO keeps) is gated by IAcTypeBinder per the consolidated plan in BINARY_AQN_IMPLEMENTATION.md. The [AcBinaryUnion] SGen path proposed here bypasses Type.GetType entirely (closed switch (tag) dispatch in code-gen), so it is AOT-friendly and inherently security-clean (no wire-supplied type-name resolution at all). The two TODOs are complementary, not overlapping — U6Y8 is the fast/AOT alternative; the binder secures the legacy AQN path.
Design philosophy alignment: AcBinary's market positioning is "JSON-style flexibility with MessagePack-class speed" — attributes are opt-in optimization, never required. The runtime polymorphism path (AQN-based, today's default) stays the default and continues to work for arbitrary unattributed types. This TODO adds a fast/AOT path alongside it, never replaces it.
AcBinary today handles polymorphism at runtime: the wire writes ObjectWithTypeName(72) + AQN string, and the deserializer calls Type.GetType(aqn) to resolve. This is flexible (no upfront declaration), but has three significant drawbacks for some consumers:
- AOT-incompatible —
Type.GetType(AQN)requires reflection metadata that the Native AOT trimmer strips by default. The runtime polymorphism path does not work at all under Native AOT. Hard blocker for AOT-targeting consumers (Blazor WASM, MAUI mobile, container-trimmed deployments). - Slower — AQN string parse + reflection lookup vs. a closed
switch (tag)in code-gen. - Larger wire format — full AQN string (often 100+ bytes) vs. a single-byte
tag.
Design — three coordinated pieces:
1. New 5th bool parameter on [AcBinarySerializable]: EnablePolymorphismFeature
Mirrors the existing EnableMetadataFeature / EnableIdTrackingFeature / EnableRefHandlingFeature / EnableInternStringFeature pattern. Per-type opt-out / opt-in via attribute parameter.
public AcBinarySerializableAttribute(
bool enableMetadataFeature,
bool enableIdTrackingFeature,
bool enableRefHandlingFeature,
bool enableInternStringFeature,
bool enablePolymorphismFeature) // ← ÚJ, default: true
Three behavior modes per type:
EnablePolymorphismFeature = false→ disabled. SGen never emits polymorphism dispatch for this type; runtime path also short-circuits — runtime type ≠ declared type is silently treated as declared (or throws, decision TBD). Use for hot-path closed types where polymorphism is impossible-by-design and the perf/AOT cost is unwanted.EnablePolymorphismFeature = true(default), no[AcBinaryUnion]→ runtime options control. Behaves perAcBinarySerializerOptions.PolymorphismMode(Runtime/AQN today). This preserves the JSON-style flexibility for unattributed bases.EnablePolymorphismFeature = true+[AcBinaryUnion(...)]declared → union-switch dispatch. SGen emits a closedswitch (tag)dispatch using the declared subtype set. Fast + AOT-friendly. Overrides the options-level default for this type.
2. New [AcBinaryUnion(byte tag, Type subtype)] attribute
Multiple instances per base class / interface declare the closed polymorphism set:
[AcBinarySerializable] // EnablePolymorphismFeature defaults to true
[AcBinaryUnion(0, typeof(Cat))]
[AcBinaryUnion(1, typeof(Dog))]
public abstract partial class Animal { ... }
SGen detects [AcBinaryUnion] on abstract / base type → emits the switch-based write/read dispatch instead of falling through to runtime AQN.
3. New PolymorphismMode enum on AcBinarySerializerOptions
Options-level default for unattributed polymorphism (i.e. the case where EnablePolymorphismFeature = true but no [AcBinaryUnion] is declared):
Runtime(today's default) — AQN-based. Flexible, AOT-incompatible.Throw— fail fast on any polymorphic write that lacks a[AcBinaryUnion]attribute. AOT-friendly diagnostic mode for migration scenarios.
Note: there is no UnionAttribute-only mode — declaration is per-type via the attribute, not options-global. The options-level mode only governs the fallback when no [AcBinaryUnion] is present.
Wire-format addition:
New marker (e.g. UnionTagBase = <TBD>) + [byte tag][inner Object], parallel to existing ObjectWithTypeName(72). Slot number to be assigned avoiding clashes with existing 64–134 / 192–255 ranges.
Implementation outline:
AcBinarySerializableAttribute— new ctor parameterenablePolymorphismFeature, all existing ctors default it totrue(backward compatible).AcBinaryUnionAttribute— new attribute,AttributeUsage(AttributeTargets.Class | Interface, AllowMultiple = true).- Source generator — emit
WriteUnion<TBase>(value, ctx, depth)andReadUnion<TBase>(ctx, depth)static methods on the union-base type's generated writer/reader. Skipped entirely whenEnablePolymorphismFeature = false. - Wire-format new marker +
[byte tag][inner Object]body. - Runtime path:
WriteValueNonPrimitivechecks the wrapper'sPolymorphismFeatureEnabledflag; whenfalse, skips thevalue.GetType() != declaredTypepolymorphism branch entirely.
Acceptance:
EnablePolymorphismFeature = false: SGen-emitted dispatch contains zerois-typeof / GetType branches; runtime path also short-circuits. Verify in JIT disassembly.EnablePolymorphismFeature = true, no union: runtime AQN polymorphism works as today (full backward compat); preserved JSON-style flexibility for unattributed bases.EnablePolymorphismFeature = true+[AcBinaryUnion]: AOT-test (Native AOT publish) compiles and round-trips a polymorphic graph —Type.GetType()is never invoked on this path.- Benchmark: union-switch polymorphism measurably faster than AQN polymorphism on deser side (typed switch vs. reflection lookup).
- Wire format documented in
BINARY_FORMAT.md;BINARY_FEATURES.mdcross-references the attribute pattern;BINARY_OPTIONS.mddocumentsPolymorphismMode.AcBinarySerializableAttributedoc-string explains all three behavior modes.
ACCORE-BIN-T-B7H4: Implement AcBinarySerializerOptions thread-safety fix
Priority: P2 · Type: Refactor · Related: BINARY_ISSUES.md#accore-bin-i-l8n5 (canonical issue)
The latent thread-safety problem documented in ACCORE-BIN-I-L8N5 — mutable set; properties on AcBinarySerializerOptions shared across concurrent serialize/deserialize calls — needs a fix before AcBinary ships as a NuGet package. The package cannot constrain how consumers scope their options instances; defensive contract is needed in the serializer itself.
Three candidate fix directions (decide before implementing):
-
Defensive copy on ingress — add
AcBinarySerializerOptions Clone()method (member-wise copy). Every API entry point that retains an options instance clones it on entry. External mutation to the original becomes invisible to the holder.- Pro: non-breaking. Existing consumer code unchanged. No major version bump required.
- Pro: API surface change limited to one new
Clone()method. - Con: per-call clone overhead (small, but non-zero). Cache keyed on options-identity becomes invalid for downstream code using reference equality.
- Con: doesn't fix the underlying mutability — internal code can still race-mutate the cloned snapshot if a method retains both the snapshot and modifies it concurrently.
-
Immutable record refactor —
set;→init;on all configuration properties. Mutation requireswith-expression which produces a new instance.- Pro: type-system-strong guarantee. Race becomes a compile error, not a runtime corruption risk.
- Pro: zero runtime overhead (init-only is compile-time check; record class semantics are unchanged at runtime).
- Con: breaking change for any consumer doing
opts.UseGeneratedCode = falseafter construction. Major version bump. - Con: source-generator coordination needed if SGen emits options-builder code that mutates properties.
-
Read-only flag pattern (à la
JsonSerializerOptions.MakeReadOnly()) — mutable by default, holder callsMakeReadOnly()on entry; subsequent property setters throwInvalidOperationException.- Pro: BCL-precedent — Microsoft adopted it for
JsonSerializerOptionsin .NET 7 (dotnet/runtime#74431) for exactly this problem. Familiar pattern for consumers. - Pro: minimal API surface change (one new method +
IsReadOnlyflag property). - Pro: per-call overhead = single bool check per setter call. Negligible.
- Con: opt-in by the holder — if a custom consumer-side wrapper forgets to call
MakeReadOnly(), the safety hole stays open for that wrapper's clients. Documentation-driven safety, not type-system-driven. - Con: bypasses static-analysis tooling (the setter signature stays public; the throw is runtime). IDE doesn't surface "this property is currently read-only" in autocomplete.
- Pro: BCL-precedent — Microsoft adopted it for
Recommendation: Option 3 (MakeReadOnly pattern) is the BCL-precedent, lowest-friction migration path. Microsoft adopted it for JsonSerializerOptions in .NET 7 to solve the same problem; AcBinary should follow the same pattern for consistency with consumers' mental model and zero migration cost.
Coordination with the existing AcBinaryHubProtocol setter side-effect (the second risk surface in ACCORE-BIN-I-L8N5): the protocol ctor currently mutates the caller-provided options reference (_options.BufferWriterChunkSize = options.BufferSize). After the fix:
- Option 1 (Clone): ctor mutates the cloned snapshot → no side-channel to the caller. Fix transparent.
- Option 2 (Immutable): ctor cannot mutate; needs to construct a new options via
with-expression. Breaking change in the ctor's options-handling. - Option 3 (MakeReadOnly): ctor mutates before calling
MakeReadOnly()— same as today, but explicit "frozen" point afterwards. Caller-side mutation post-ctor is now a runtime throw.
Implementation outline (Option 3 path):
AcBinarySerializerOptions.IsReadOnly { get; }— public bool property.AcBinarySerializerOptions.MakeReadOnly()— sets the flag; idempotent (no-op if already set).- All
set;accessors guard:if (IsReadOnly) throw new InvalidOperationException("AcBinarySerializerOptions has been made read-only and can no longer be mutated. Construct a new options instance instead.");. AcBinarySerializer.Serialize<T>entry (and all sibling entries —Deserialize<T>,SerializeChunked, etc.):options.MakeReadOnly()before any property read.AcBinaryHubProtocolctor: complete theBufferWriterChunkSizemutation before callingoptions.MakeReadOnly(). After ctor returns, the options instance is frozen for that protocol's lifetime.- Doc-string update on
AcBinarySerializerOptionsclass header: explicit "thread-safety contract" section explaining the freeze-on-first-use semantics.
Acceptance:
- Concurrent stress test (16 threads × 1000 iterations) on a shared
AcBinarySerializerOptionsinstance with property-mutation-attempts mid-iteration — all mutations afterMakeReadOnly()throwInvalidOperationException; no silent corruption observed. - Existing tests pass unchanged (the
MakeReadOnlyis opt-in for the serializer entries; tests that build options + use them once continue to work transparently). BINARY_ISSUES.md#accore-bin-i-l8n5Status updated toClosed (YYYY-MM-DD)with a### Resolutionsub-section pointing to this TODO + the implementing commit.- Doc-string on
AcBinarySerializerOptionsdocuments the freeze-on-first-use contract;BINARY_FEATURES.mdorBINARY_OPTIONS.mdcross-references the BCL-precedent (JsonSerializerOptions.MakeReadOnly).
ACCORE-BIN-T-F8N3: Switch source-generator type-name hashing from simple-name to fully-qualified-name
Priority: P3 · Type: Refactor · Related: ACCORE-BIN-T-I3P8 (override mechanism for residual collisions)
The source generator's ComputeFnvHash(typeSymbol.Name) uses the simple name only (e.g. "User", not "MyApp.A.User"). Cross-namespace types with the same simple name silently collide on s_typeNameHash. The hash is currently only consumed by the WireMode=Metadata inline metadata-write path (cross-version property compat) — the framework explicitly does NOT add wire-format type-id (per CLAUDE.md Rule #7: type-dispatch is consumer responsibility, see BINARY_ASYNCPIPE_ISSUES.md#accore-bin-i-t6v2). Within UseMetadata, the simple-name collision can still cause silent property-set mismatches between two types with the same short name in different namespaces — this TODO fixes that.
Change scope (AcBinarySourceGenerator.cs) — 4 call sites: ComputeFnvHash(typeSymbol.Name) → ComputeFnvHash(typeSymbol.ToDisplayString()):
- Self type-name hash (~line 358)
- Child type-name hash (~line 157)
- Element type-name hash (~line 254)
- Dict-value type-name hash (~line 311)
No runtime code changes; output regenerates with new constants on next build.
Breaking change scope: any saved binary stream that uses WireMode=Metadata and was produced by an older version embeds the old simple-name hash; consumers reading those streams with the new hash compute would mismatch and throw. Pre-1.0: acceptable. Post-1.0 would require a WireMode=Metadata format-version bump.
Acceptance:
- All
*_GeneratedWriter.g.csfiles regenerate with FQN-baseds_typeNameHashvalues. - Existing tests pass (auto-regen propagates; no manual hash literals in tests).
- Wire format identical for
WireMode=Compact(no metadata embedded). UseMetadata=truepaths produce different hashes — explicitly tested via round-trip.
ACCORE-BIN-T-I3P8: [AcBinaryTypeId(...)] attribute — explicit type-id override
Priority: P3 · Type: Feature · Related: ACCORE-BIN-T-F8N3 (FQN base hash being overridden)
Once ACCORE-BIN-T-F8N3 reduces collision frequency by switching to FQN, residual FQN-hash collisions are still possible (32-bit hash space, birthday paradox). Currently the only consumer of s_typeNameHash is the WireMode=Metadata inline metadata-write path — a residual collision there causes a silent property-set mismatch.
[AcBinaryTypeId(0x12345)] attribute on a class:
- Source generator emits
s_typeNameHash = 0x12345instead of computing FNV. - Two types with the same
[AcBinaryTypeId(...)]value → compile-time / first-use error.
Useful for:
- Resolving rare FQN-hash collisions deterministically (within
WireMode=Metadata). - Pinning a stable type-id across class renames (wire-compat across versions in
Metadatamode). - Future-proofing: if a Layer 1 consumer (hypothetically) builds a type-dispatch above AcBinary using
s_typeNameHash, the same override mechanism applies.
Acceptance:
- New attribute class shipped alongside
[AcBinarySerializable]. - Generator honours the override (emits explicit constant instead of FNV result).
- Tests: rename a class with
[AcBinaryTypeId]→s_typeNameHashunchanged.
ACCORE-BIN-T-X2M5: Evaluate xxHash3 vs FNV-1a for type-name hashes
Priority: P3 · Type: Investigation · Related: ACCORE-BIN-T-F8N3
FNV-1a is currently used for both s_typeNameHash and s_propertyHashes. For compile-time hashing, performance is irrelevant. For collision resistance:
- FNV-1a 32-bit: ~50% collision at ~77K types (birthday paradox). Adequate for small/medium projects, marginal for large ones with many auto-generated types.
- xxHash3 32-bit: comparable mathematical properties to FNV-1a (both non-cryptographic).
- xxHash3 64-bit: dramatically better collision resistance (~50% at ~5B entries), at the cost of 8 wire bytes instead of 4.
Trigger: real collisions observed (1000+ types per assembly + cross-assembly aggregation), or community feedback indicating collision pain.
Investigation questions (no code change without a triggering pain signal):
- Switch to xxHash3 32-bit (incremental improvement) — but doubles the change scope (touch property hashes too if uniformity desired).
- Switch to xxHash3 64-bit (8 wire bytes instead of 4) — meaningful collision resistance, modest wire cost.
- Stay on FNV-1a + force
[AcBinaryTypeId]for collisions — minimal change, devops burden.
Investigation only — defer until pain signal arrives.
ACCORE-BIN-T-K9E4: [RequiresDynamicCode] + [RequiresUnreferencedCode] on Runtime-only methods
Priority: P3 · Type: Refactor · Related: BINARY_FEATURES.md#nativeaot-compatibility
The Runtime path (factories in AcSerializerCommon + wrapper-based deserialize fallback in AcBinaryDeserializer) currently works under NativeAOT thanks to DAMs propagation + RuntimeFeature.IsDynamicCodeSupported guards, but the trimmer still emits warnings for the well-known blind spots (polymorphism via obj.GetType(), nested-type chain via generic argument extraction). The library suppresses these with [UnconditionalSuppressMessage] and documented justification.
A complementary signal would be to mark the Runtime entry points (or the factories themselves) with [RequiresDynamicCode("AcBinary Runtime path uses Reflection.Emit / closed-generic instantiation; use [AcBinarySerializable] + SGen for NativeAOT.")] and [RequiresUnreferencedCode("...")]. Effect:
- AOT publish in consumer's project surfaces a warning at the call site → consumer chooses SGen or accepts the Runtime cost
- Mirrors the System.Text.Json reflection-mode pattern (
[RequiresDynamicCode]onJsonSerializer.Serialize<T>overloads) - One-codebase, no NuGet split needed
- Cheap implementation — attribute placement only
Coordination: [RequiresDynamicCode] is contagious; every caller must either propagate it or suppress with [UnconditionalSuppressMessage]. Scope:
- Public
Serialize<T>/Deserialize<T>entry points stay attribute-free (consumer-facing) - Runtime fallback methods get the attribute (contained inside the library)
- The DAMs annotations we already have stay — they're orthogonal (one prevents trim, the other warns about JIT-only behavior)
Acceptance:
- Consumer's AOT publish surfaces a IL2026/IL3050 warning when
UseGeneratedCode=falseis set or an unattributed type is deserialized - SGen path is warning-free
- Library compiles 0 warnings (suppressions added at the propagation barrier)
BINARY_FEATURES.mdNativeAOT Compatibility section updated to mention the explicit warning signal
ACCORE-BIN-T-A2J7: Optional AyCode.Core.Aot NuGet variant (SGen-only build)
Priority: P3 · Type: Feature · Related: BINARY_FEATURES.md#nativeaot-compatibility, ACCORE-BIN-T-K9E4
Binary-size-sensitive AOT consumers (Blazor WASM, MAUI mobile, embedded, container-trimmed) benefit from a smaller library variant that strips the Runtime fallback path entirely. Estimated savings: ~80-150 KB of native code (~25-60 KB compressed wire size for WASM publish).
Strippable code in the .Aot variant:
| Component | LOC | Purpose | Removable in Aot? |
|---|---|---|---|
AcSerializerCommon.Create* (7 factory methods + Expression-tree code) |
~150 | Runtime delegate compilation | ✅ Yes |
TypeMetadataBase runtime metadata path (CompiledConstructor, IdGetters via Expression.Compile) |
~300 | Reflection-based metadata | ✅ Yes |
AcBinaryDeserializer wrapper-based runtime fallback (PopulateObjectPropertiesIndexed, ReadObjectCoreWithWrapper non-SGen branches, CreateInstance(type) Activator-fallback) |
~500 | Runtime polymorphic dispatch | ✅ Yes |
Property accessor runtime delegate fields (_dynamicGetter, typed getter/setter caches outside SGen) |
~150 | Boxed property access | ✅ Yes |
System.Linq.Expressions transitive dependency |
— | Expression-tree IL emission | ✅ Yes (when nothing else in graph uses it) |
Implementation sketch (avoid #if-erdő via file-level split):
AyCode.Core/Serializers/
AcSerializerCommon.cs // SGen-safe shared parts
AcSerializerCommon.Runtime.cs // 7 Create* factory methods only here
AcBinaryDeserializer.cs // SGen path
AcBinaryDeserializer.Runtime.cs // wrapper-based runtime fallback path
TypeMetadataBase.cs // SGen-safe metadata
TypeMetadataBase.Runtime.cs // Expression.Compile-based ctor + accessor wiring
Two .csproj files:
AyCode.Core.csproj— full package (current); includes all filesAyCode.Core.Aot.csproj—<Compile Remove="**/*.Runtime.cs" />; sets<PackageId>AyCode.Core.Aot</PackageId>; same version as full
Trade-offs:
- ✅ No
#ifdirectives in business code — physically separate file groups - ✅ Source mostly shared via SDK include/exclude semantics
- ✅ DAMs annotations and trim-suppressions only land in the full package;
.Aotvariant is genuinely trim-clean by construction - ✅ "Strict SGen" semantics in
.Aot: a non-SGen type at deser time throws clearly instead of silently falling back. Marketing positioning: "guaranteed SGen path, no hidden slow lane". - ⚠️ Two NuGet IDs, two changelogs, version sync (CI-automatable)
- ⚠️ Consumer must pick the right package — wrong choice = breaking switch later
Coordination:
- Land
ACCORE-BIN-T-K9E4first ([RequiresDynamicCode]attributes) — if that pattern handles the consumer-side scenarios well,.Aotmay not be needed - The current Runtime fallback code is already well-isolated (mostly in
AcSerializerCommonfactories +AcBinaryDeserializerwrapper-based methods), so the file-split refactor is mechanically straightforward - Marketing decision: is binary size a central pillar? If yes,
.Aotis a NuGet differentiator; if not,K9E4alone is enough
Acceptance:
AyCode.Core.Aot.csprojproduces a NuGet ~25-60 KB smaller thanAyCode.Coreafter compression.Aotbuild emits zero IL/AOT trim warnings (no suppressions needed because the Runtime path code is physically removed)- Round-trip tests pass on
.Aotfor all SGen types .Aotthrows a clearInvalidOperationException(notMissingMethodException) when a non-[AcBinarySerializable]type is encountered at deser timeBINARY_FEATURES.mdNativeAOT Compatibility section documents both packages and when to choose which
ACCORE-BIN-T-V4N2: Cross-tier SIMD UTF-8 transcoder paths (AVX-512BW + Vector128 + multi-byte transcoder)
Priority: P2 · Type: Performance · Related: EncodeUtf8SinglePass, DecodeUtf8SinglePass, CountUtf8Chars
Current SIMD hierarchy (post 2026-05-05 implementation):
AVX-512BW (64 byte/iter) → Server, Intel 11th gen client, AMD Zen 4+
Vector256 / AVX2 (32 byte) → AVX2 host (Intel 12-14th gen, AMD Zen 3 and earlier)
Vector128 (16 byte/iter) → Apple Silicon NEON, WASM SIMD, legacy SSE2
scalar (1 byte/iter) → no-SIMD fall-back
JIT/AOT path-selection via [Intrinsic] IsSupported static booleans — non-supported tiers constant-folded to dead code per host. Cascading tail handlers: a higher tier's tail (< 64 byte AVX-512 → < 32 byte Vector256 → < 16 byte Vector128 → scalar) is processed by the next-lower tier on the same iteration. No regression on any host.
Implementation status:
| Phase | Method | AVX-512BW | Vector256 | Vector128 | scalar |
|---|---|---|---|---|---|
| 1 | CountUtf8Chars (decode 1st pass) |
✅ done | ✅ existing | ✅ done | ✅ existing |
| 2 | EncodeUtf8SinglePass Phase 1 (ASCII narrow) |
✅ done | ✅ existing | ✅ done | ✅ existing |
| 2.5 | DecodeUtf8SinglePass scalar run-length decoder (multi-byte baseline) |
— | — | — | ⏳ TODO |
| 3a | DecodeUtf8SinglePass multi-byte transcoder (Vector512) |
⏳ TODO | bail-out only | bail-out only | ✅ existing |
| 3b | DecodeUtf8SinglePass multi-byte transcoder (Vector256) |
— | 🔍 deferred — see note | bail-out only | ✅ existing |
| 3c | DecodeUtf8SinglePass multi-byte transcoder (Vector128) |
— | — | ⏳ TODO | ✅ existing |
Note on Phase 3b (Vector256 / AVX2) — deferred, not dropped. AVX2 lacks the AVX-512BW primitives (CompareEqualMask producing a __mmask k-register, in-lane vpermb, mask-driven vpcompressb) that make the classify-mask-compress-widen pipeline efficient. The Vector256.Shuffle is cross-lane via two vpshufb (per-128-bit-lane), which complicates leader-byte extraction across multi-byte sequences spanning the lane boundary. The simdutf C++ project — the canonical reference for this algorithm class — implements only SSE4 (16-byte) and AVX-512 (64-byte) paths; it explicitly skips AVX2 because the implementation cost-benefit is unfavorable on this algorithm.
On AVX2 hosts, the Phase 3c (Vector128) transcoder runs as the primer multi-byte path AND as tail handler — covering AVX2 hosts with 16-byte/iter, which is already a significant win over the current scalar multi-byte branch. Phase 3b would require either:
- Hand-rolling an AVX2-specific 32-byte algorithm with cross-lane permute workarounds (research-grade complexity, uncertain net win — could be SLOWER than the Vector128 path due to cross-lane shuffle latency)
- Waiting for
Avx10v1/Avx10v2to expose AVX-512BW-class primitives in 256-bit form (Intel's unified vector ISA —Avx10v1already in .NET 9,Avx10v2arrives with future Intel hardware)
Re-evaluation triggers: if benchmark on AVX2 hosts shows Phase 3c Vector128 path leaves > 10% Deser gap vs MemPack on multi-byte content; or if Avx10v1 256-bit primitives mature enough to make the algorithm tractable. Until then: Phase 3b stays in the TODO as a research / future-work item — not actively scheduled, but documented so a future contributor doesn't re-derive the AVX2 limitations.
Phase 3 is the remaining gap — UTF-8 multi-byte decode on every host class. ASCII path is already fast across all SIMD tiers (Vector256 + Vector128 prefix widen + Encoding.Latin1.GetString BCL fast path). The gap is on multi-byte UTF-8 content — Hungarian / Cyrillic / Greek (2-byte) and CJK BMP (3-byte) sequences — where the SIMD prefix bails out on the first non-ASCII byte and falls back to scalar bit-extract. The Repeated benchmark cell (Hungarian content) is the canonical witness; with all-Hungarian content (current bench data), Small / Repeated Deser cells trail MemPack by 6-14%.
Why all 3 SIMD tiers (not just AVX-512BW) — public NuGet package goal: i18n payloads must be fast on every supported host (cloud server, desktop, mobile, Blazor WASM), not only AVX-512-capable cloud servers. The saját scalar multi-byte branch is the bottleneck on all non-ASCII content regardless of host class. The BCL Encoding.UTF8 falls back to a similar scalar path on multi-byte content (with virtual dispatch + EncoderFallback overhead), so even where the BCL has its own SIMD 2-byte handler (.NET 9 PR #92580), our trust-input scalar wins on net — but a saját SIMD multi-byte path would dominate on every host.
Phase 3 approach — in-house multi-byte transcoder, three SIMD widths. Single algorithm template (classify-mask-compress-widen pipeline) ported across Vector512 / Vector256 / Vector128 register widths. Algorithm designed and written in-house — no third-party port, no NuGet dependency:
- Phase 3a —
DecodeUtf8SinglePassVector512 (AVX-512BW): 64-byte block fetch → classify each byte's UTF-8 sequence position via mask compares → byte-compression for length-resolution → widen to UTF-16 in twoVector256<ushort>lanes → store. ~3-5× speedup vs current scalar multi-byte branch on Hungarian / CJK content. Activates on AVX-512 hosts (cloud server, Intel 11th gen, AMD Zen 4+). - Phase 3b —
DecodeUtf8SinglePassVector256 (AVX2): same algorithm at 32-byte block. Smaller register space → fewer codepoints per iter, but ASCII bail-out gone → multi-byte content is now SIMD-handled. ~2-3× speedup. Activates on AVX2 hosts (Intel 12-14th gen, AMD Zen 3 and earlier). - Phase 3c —
DecodeUtf8SinglePassVector128 (NEON / SSE / WASM SIMD): same algorithm at 16-byte block. ~1.5-2× speedup. Activates on Apple Silicon / WASM / legacy x86 — covering the i18n production case for mobile (MAUI iOS / Android) and Blazor WASM.
The cascading tail-handler hierarchy (existing in Phase 1+2) carries over: AVX-512 → Vector256 → Vector128 → scalar tail. Each tier hands off the < N-byte tail to the next-lower tier.
No .NET 11 / multi-targeting needed. Avx512BW, Vector256, Vector128 intrinsics all available in .NET 9 (and .NET 8). Implementation lands on the current net9.0 target.
Hardware reach (2026). Per Wikipedia "CPUs with AVX-512":
- ✅ Intel server: Skylake-X (2017), Cascade Lake-X, Ice Lake-SP, Sapphire Rapids (2023+), Emerald Rapids, Granite Rapids — near-universal in cloud (Azure, AWS, GCP)
- ✅ Intel client 11th gen: Tiger Lake (mobile, 2020), Rocket Lake (desktop, 2021), Ice Lake (mobile) — pre-Alder Lake era still supports AVX-512
- ❌ Intel client 12-14th gen: Alder Lake / Raptor Lake / Meteor Lake / Core Ultra — AVX-512 disabled at firmware level (E-core blocking) → falls back to Vector256
- ✅ AMD Zen 4+: Ryzen 7000 (2022), Ryzen 9000 (2024), EPYC Genoa (2022), EPYC Turin (2024)
- ❌ AMD pre-Zen 4: Zen 3 and earlier → falls back to Vector256
- ❌ Apple Silicon / ARM: NEON only → uses Vector128 (16 byte/iter)
- ❌ Blazor WASM: only 128-bit SIMD per WASM SIMD spec → uses Vector128 (16 byte/iter)
The Vector128 path is the WASM and Apple Silicon target — without it both platforms fell back to scalar (1 byte/iter). With Phase 1+2 landed, WASM and Apple Silicon now run the UTF-8 hot path at 16 byte/iter (16× scalar speedup on the count + ASCII narrow operations).
Phase 2.5 — scalar run-length decoder (multi-byte baseline, pre-Phase 3 prototype) — TESTED & REVERTED 2026-05-07
Status update (2026-05-07): Phase 2.5 was implemented and tested in two configurations:
- Full run-length (15:56:54 bench) — both 2-byte and 3-byte tiers used inner do-while loops. Result: +13.0 pp Deser regression on the Hungarian-mixed Repeated cell. Hypothesis confirmed (foreseen pre-implementation): rövid Magyar 2-byte runs (1-2 char average) make the run-detection overhead exceed the per-char payload; switch-jumptable per-char dispatch wins on this content shape.
- Hybrid (post-15:56:54) — 2-byte single decode, 3-byte run do-while only. Tested but bench-zaj instabilitás miatt unmeasurable signal. Reverted along with V4N4 method-split (2026-05-07).
The optimization-value signal proved below the bench noise floor on the available hardware. The 3-byte do-while CJK-content win remains a theoretically valid target — but cannot be objectively validated without the ACCORE-BIN-T-C5R8 charset-parameterized benchmark workload (CJK option). Re-evaluate when CJK workload measurement becomes available.
Re-evaluable as of 2026-05-07 per ACCORE-BIN-T-D9X3 — bench stabilization removes the noise-floor that made the original signal unmeasurable; retest before any code change. (Charset bias remains — pair with ACCORE-BIN-T-C5R8 for CJK validation.)
Retested 2026-05-08 — REGRESSION CONFIRMED (Latin1Long charset, stabilized bench): adding the do-while inner loop on both 2-byte and 3-byte tiers in DecodeUtf8SinglePass produced +5-8pp Deser regression on every cell vs. the switch-jumptable baseline (Small +7.8pp, Medium +7.1pp, Large +5.5pp, Repeated +7.4pp, Deep +4.9pp). Reverted to switch-jumptable single-decode same day. The V4N2 entry's original prediction held: "Magyar mixed (KözösCímke, sötét — short alternating runs): 0-5% (run-detection overhead may eat the savings on short runs)" — Latin1Long suffix has 1-2 char average run length, well below the run-detection break-even point. Phase 2.5 is dead on Magyar mixed. CJK retest still untried, but Phase 2.5 is now obsoleted by ACCORE-BIN-T-K7M3 (the decoder hot path runs Utf8.ToUtf16 BCL static API, not DecodeUtf8SinglePass).
Below: original Phase 2.5 design notes preserved as documentation. Implementation details remain accurate even though the implementation was reverted.
Targets the DecodeUtf8SinglePass switch-jumptable per-char dispatch on multi-byte content. Current scalar Phase (jumptable) re-dispatches every char; a run-length-aware scalar decoder runs a tight branchless inner loop on homogeneous runs (long ASCII run, long 2-byte Latin/Cyrillic run, long 3-byte CJK BMP run), with the existing single-codepoint scalar branch as mixed-edge fallback.
Algorithm sketch:
while (s < src.Length)
{
// 1) ASCII run (0xxxxxxx) — already handled by Phase 1 SIMD prefix; this is tail
int asciiStart = s;
while (s < src.Length && src[s] < 0x80) s++;
if (s > asciiStart) { WriteAsciiRun(src.Slice(asciiStart, s-asciiStart), dst, ref d); continue; }
// 2) 2-byte run (110xxxxx 10xxxxxx) — Hungarian / Cyrillic / Greek / Hebrew / Arabic
int twoStart = s;
while (s + 1 < src.Length && Is2ByteLead(src[s]) && IsCont(src[s+1])) s += 2;
if (s > twoStart) { Decode2ByteRun(src.Slice(twoStart, s-twoStart), dst, ref d); continue; }
// 3) 3-byte run (1110xxxx 10xxxxxx 10xxxxxx) — CJK BMP, other 3-byte BMP scripts
int threeStart = s;
while (s + 2 < src.Length && Is3ByteLead(src[s]) && IsCont(src[s+1]) && IsCont(src[s+2])) s += 3;
if (s > threeStart) { Decode3ByteRun(src.Slice(threeStart, s-threeStart), dst, ref d); continue; }
// 4) Mixed-edge fallback (typically 4-byte surrogate pair or single transition char)
DecodeSingleCodePoint(src, ref s, dst, ref d);
}
Why P2.5 — scalar baseline before SIMD multi-byte (Phase 3a-3c):
- 1-2h prototyping cost vs 6-10h Phase 3 SIMD work
- A/B benchmark on Repeated cell decides whether the run-length structure already wins on Magyar mixed (
KözösCímkepattern) — if it does, Phase 3 lifts further; if not, Phase 3 SIMD is the only win path - Documents the "switch-jumptable bottleneck on Hungarian benchmark" hypothesis without committing to the larger SIMD effort
- The
Decode2ByteRun/Decode3ByteRunscalar-batch implementations also serve as algorithm references for the Phase 3 SIMD versions (clear semantics first, optimize after)
Expected payoff (per content class, ratio vs current switch-jumptable):
- Long CJK BMP (3-byte run, e.g.
你好世界×30): ~20-40% Deser improvement (long homogeneous run, biggest jumptable savings) - Long 2-byte run (
árvíztűrő×10+): ~5-15% improvement - Magyar mixed (
KözösCímke,sötét— short alternating runs): 0-5% (run-detection overhead may eat the savings on short runs) - Long ASCII (≥32 byte): 0% (Phase 1 SIMD prefix already handles)
- Emoji (4-byte): 0% (mixed-edge fallback unchanged)
Risk — the existing switch-jumptable JIT optimization is strong; Magyar mixed text (1-2 char runs) may not show net gain. Implementation must be isolated prototype first (alongside the live DecodeUtf8SinglePass, not replacing it), with A/B benchmark comparing the two before any switch.
Acceptance (Phase 2.5):
- Repeated cell Compact Deser ratio ≤ 1.0 vs MemPack on AVX2 hosts (parity with current measurement, no regression)
- Round-trip tests pass on all UTF-8 content classes (ASCII / 2-byte / 3-byte BMP / 4-byte surrogate-pair)
- A/B benchmark shows ≥ 5% Deser improvement on Repeated OR ≥ 10% on Large cell — else Phase 2.5 stays in TODO as documented dead-end (negative result is also valuable: confirms the jumptable is fast enough, focus moves entirely to Phase 3)
Phase 3 implementation outline
- Insert SIMD multi-byte branches at
DecodeUtf8SinglePassentry, before the existing ASCII-prefix bail-out loops:if (Avx512BW.IsSupported && byteCount >= 64) { Vector512MultiByteDecode(...) } if (Vector256.IsHardwareAccelerated && len-i >= 32) { Vector256MultiByteDecode(...) } if (Vector128.IsHardwareAccelerated && len-i >= 16) { Vector128MultiByteDecode(...) } // existing scalar tail - Single algorithm template — classify-mask-compress-widen pipeline:
- Block load (Vector512 / Vector256 / Vector128)
- Classify each byte's UTF-8 sequence position via mask compares (start vs continuation, 1/2/3/4-byte sequence width)
- Compute output char count via popcount on start-byte mask + extra-char mask for 4-byte sequences
- Byte-compression for leader/continuation extraction (mask-driven
PermuteVar/Shuffle) - Combine leader + continuations into codepoints (shift + OR)
- Widen codepoints to UTF-16 chars (handle surrogate pairs for 4-byte sequences)
- Store output, advance src/dst pointers
- Block-boundary edge case: incomplete multi-byte sequence at block end → carry to next iter or hand off to lower tier / scalar tail
- Trust-input semantics maintained — no validate-pass instructions (reader input is valid UTF-8 by writer contract)
Avx512BW.X64.IsSupported(64-bit-only intrinsics) checked separately if any code path requires the X64 sub-feature
Why P2
- "i18n production deploy" perf gap on every host class — the public NuGet package contract requires fast multi-byte UTF-8 across cloud server, desktop, mobile, and Blazor WASM
- No NuGet dependency, no third-party code, no wire-format change, additive — pure CPU optimization
- Phase 1+2 delivered cross-tier ASCII / count SIMD coverage; Phase 3 closes the multi-byte CPU gap on all SIMD-capable hosts (not just AVX-512)
- Single algorithm template ported across 3 register widths — code volume manageable
Acceptance
- Repeated Deser ratio ≤ 0.7 vs MemPack on AVX-512 hosts (Phase 3a)
- Repeated Deser ratio ≤ 0.8 vs MemPack on AVX2 hosts (Phase 3b)
- Repeated Deser ratio ≤ 0.85 vs MemPack on Apple Silicon / WASM (Phase 3c)
- Repeated Ser ratio ≤ 0.85 across all host classes
- Round-trip tests pass on all UTF-8 content classes (ASCII / 2-byte / 3-byte BMP / 4-byte surrogate-pair)
BINARY_FEATURES.mddocuments the SIMD path selection across all four tiers
Trigger
- Each SIMD width validated on a representative host before merge:
- Phase 3a: AVX-512 host (developer's local AMD Zen 4+ desktop, Intel 11th gen, or server-class machine)
- Phase 3b: AVX2 host (any modern x86 desktop / laptop without AVX-512)
- Phase 3c: Apple Silicon (macOS / iOS / Mac Catalyst) AND Blazor WASM browser runtime
- Local
dotnet testcovers correctness; per-tier benchmarks measure the multi-byte speedup - Phase 1+2 (AVX-512BW + Vector128 in
CountUtf8Chars+EncodeUtf8SinglePassPhase 1) landed 2026-05-05 — covered by existing round-trip tests, no regression on non-AVX-512 hosts (validated on AVX2-host bench)
ACCORE-BIN-T-S5L8: Sentinel-length encoding for strings (wire-size optimization, both modes)
Priority: P3 · Type: Wire-format optimization · Related: AcBinarySerializer.WriteString, AcBinaryDeserializer.ReadValue string dispatch
The leading string-marker byte (String / StringEmpty / Null) exists primarily to distinguish null vs empty vs non-empty before dispatching. For non-polymorphic, non-interned string properties the marker can be replaced by a single sentinel-length VarUInt:
[VarUInt sentinelLength] [content bytes if applicable]
sentinelLength == 0 → null
sentinelLength == 1 → empty string
sentinelLength == N+1 → string of N bytes/chars, content follows
MemoryPack-style encoding pattern. Applies to both Compact (UTF-8) and FastWire (UTF-16 raw) modes; the content following the sentinel differs by mode.
Per-mode impact
FastWire mode — wire layout today: [String marker][VarUInt charCount][UTF-16 raw bytes]. Sentinel saves 1 byte per non-null string.
| TestData | Current FastWire wire | Estimated with sentinel | Δ |
|---|---|---|---|
| Small | 3122 B | ~3050 B | -2% |
| Medium | 10905 B | ~10500 B | -4% |
| Large | 68603 B | ~67000 B | -2% |
| Repeated | 16244 B | ~15700 B | -3% |
| Deep | 15514 B | ~14900 B | -4% |
Closes the +1.7-8.1% FastWire wire gap vs MemoryPack to near zero or favorable while keeping AcBinary FastWire's +9-20% speed advantage.
Compact mode — wire layout today varies by length:
- Short (≤31 byte):
[FixStr+length][UTF-8 bytes]— already 1-byte marker, ties sentinel. - Long (>31 byte):
[String marker][VarUInt byteCount][UTF-8 bytes]— sentinel saves 1 byte (the marker).
Compact gain: only on long strings (>31 byte UTF-8). Estimated −1 byte per long string. Workload-dependent: if most strings are short or use interning, gain is small. If many long mixed-content strings, meaningful saving.
Limitations (both modes)
- Polymorphic
objectproperties: marker needed for type discrimination. Sentinel encoding only applies when the property type is staticallystringorstring?. - Interning incompatible: sentinel cannot express
StringInternFirst/StringInternedmarkers (those carry cache-index semantics). Interned properties keep marker-based encoding. FastWire mode already disables interning by design (consistent); Compact mode needs per-property dispatch (interned → marker, non-interned → sentinel). - Compact-mode FixStr ties: short strings (≤31 byte UTF-8) gain nothing in Compact (FixStr is already 1-byte marker+length). The optimization wins only on long strings in Compact.
Implementation outline (rough — refine when implementing)
- Writer: branch in
WriteStringon property metadata flags(IsString, IsNotInterned, IsNotPolymorphic). If sentinel-eligible, emitVarUInt sentinelLength+ content. Else fall through to existing marker-based encoding. - Reader: matching branch in property reader. If sentinel-eligible (per property metadata), read
VarUInt sentinelLength, dispatch on 0/1/N+1. - SGen: emit sentinel-encoding variant for non-polymorphic non-interned
stringtyped properties; emit existing marker-encoding for the rest. - Wire format version bump OR header flag indicating sentinel-encoding-active. (Cross-version compat policy decided when implementing.)
Trigger
- After D-2 / decoder optimization / marker-dispatch land (compact-mode focus completes)
- When wire-size positioning becomes a primary pillar for NuGet release
- Re-evaluate scope at implementation time — exact gain in Compact depends on consumer workload (long-string ratio, interning patterns)
Acceptance
- FastWire mode: AcBinary wire ≤ MemoryPack on at least 4 of 5 test cells
- Compact mode: long-string wire bytes -1 each, no regression on short or interned strings
- Speed benchmark: no regression vs current encoding (essentially zero CPU cost — sentinel is shifted bookkeeping)
- Cross-version compat: documented format version bump + clean fail on old reader / new wire mismatch
- Polymorphic + interned property test cases pass unchanged (use existing marker-based encoding)
ACCORE-BIN-T-H7K3: Hungarian / multi-byte content Ser optimization (Repeated Strings cell)
Priority: P3 · Type: Performance · Related: EncodeUtf8SinglePass Phase 3 (scalar multi-byte encode), ACCORE-BIN-T-E2F9 resolution
Status: Closed (2026-05-04) — Won't Fix (JIT-only artifact)
The Repeated Strings benchmark (Hungarian content: "TermékNév_…", "RaklapKód_…") still shows AcBinary Ser ratio ~1.04 vs MemPack across multiple runs (12-50-43 / 13-21-27 / 13-27-20 series). All other ASCII-heavy cells (Small/Medium/Large/Deep) sit in the 0.79-0.94 ratio range — Repeated is the outlier.
The Phase 3 scalar multi-byte branch in EncodeUtf8SinglePass (1-byte ASCII / 2-byte Latin-extended / 3-byte BMP / 4-byte surrogate-pair) processes Hungarian diacritics (á, é, í, ő, ű, etc.) as 2-byte UTF-8 sequences via scalar bit-extract. MemPack's UTF-8 encoder appears to use a SIMD-accelerated mixed-content lane that processes 2-byte sequences in parallel.
Resolution
AOT bench 13-40-29: Repeated Ser ratio = 0.96 (AcBinary 14.50 µs vs MemPack 15.05 µs, AcBinary GYORSABB by 4%). Deser ratio 0.91 (also faster).
The 1.04+ ratio observed in JIT-mode benchmarks (12-50-43, 13-21-27, 13-27-20) was a JIT tier-up artifact — the SGen-generated writer's hot path (which calls EncodeUtf8SinglePass) didn't reliably tier up to fully-optimized code within the 1000-iteration measurement window, while MemPack's writer apparently warmed up faster. Under NativeAOT publish (-p:_IsPublishing=true) the issue disappears completely — both writers are deterministically optimized at compile time.
No structural problem in the Phase 3 scalar branch. The investigation directions (Vector256 mixed-content lane, BCL Utf8.FromUtf16 comparison) remain valid academic improvements but show no meaningful production-time win — closing as Won't Fix.
ACCORE-BIN-T-S2X9: Markerless schema lane — drop per-property type markers for fixed-shape primitives (SGen)
Priority: P2 · Type: Wire-format extension · Related: ACCORE-BIN-T-S5L8, ACCORE-BIN-T-W7N5
AcBinary is marker-driven: every value on the wire carries a 1-byte type code, so the reader can dispatch generically (handles polymorphism, null, intern markers, type-name lookup, etc.). MemPack is schema-driven: the SGen reader knows at compile time that "field 3 is int, field 4 is string" and reads values directly with no type code, no run-time dispatch.
For fixed-shape primitive properties (int, bool, double, Guid, DateTime, …) on [AcBinarySerializable] types, the per-property type marker is pure overhead — the SGen-generated reader already has compile-time knowledge of the property type, so the marker only confirms what is already known. Dropping it on this narrow class of properties is a clean wire+CPU win without losing any of the polymorphism / null / intern flexibility that the marker provides for variable-shape values.
Why P2 — WireMode = Fast wire-size parity (NuGet release narrative)
The WireMode = Fast lane currently produces +1.7% to +8.1% larger wire than MemPack across all benchmark cells (AOT bench 13-40-29: Small +52 byte, Medium +474, Large +3617, Repeated +1221, Deep +581). The gap is structural: UTF-16 raw-memcpy strings are 2 bytes/char fixed, while MemPack's UTF-8 is 1 byte/char on ASCII content. Touching the string-write path to fix this would either:
- Lose the raw-memcpy guarantee (post-encode ASCII-detect + branchy dispatch — kills the FastWire CPU advantage), or
- Add sentinel-encoding micro-savings (~3-5% wire) which don't close the structural gap.
Markerless schema lane is the only path to wire-size parity that preserves the FastWire raw-memcpy hot path. Per-primitive-property savings (1 byte for non-tiny int, Guid, DateTime, decimal, double, …) compound on DTO-heavy payloads. Estimated effect on benchmark cells:
| Cell | Current FastWire | MemPack | Estimated post-S2X9 FastWire | vs MemPack |
|---|---|---|---|---|
| Small (~70 primitive prop) | 3122 | 3070 | ~3050 | -0.7% ✅ |
| Medium (~600 primitive prop) | 10905 | 10431 | ~10300 | -1.3% ✅ |
| Large (~6000 primitive prop) | 68603 | 64986 | ~63500 | -2.3% ✅ |
| Deep (~700 primitive prop) | 15514 | 14933 | ~14800 | -0.9% ✅ |
The Repeated cell is harder to predict (string-dominated payload, fewer primitives) — likely smaller win, may not fully close the +8.1% gap. Acceptable: the Repeated cell is a string-interning stress test, not a typical DTO workload.
NuGet release narrative: "FastMode beats MemoryPack on both wire size AND throughput across all benchmark cells" — currently we have to qualify this with "throughput-only on Compact + i18n workloads"; S2X9 removes the qualifier. This is high-leverage for the public bench shootout.
Wire savings per property type
| Type | Current encoding | Markerless lane | Wire saved |
|---|---|---|---|
int (TinyInt range −16..47) |
TinyInt (1 byte) | VarInt (1 byte) | 0 |
int (out-of-tiny) |
[Int32] [VarInt] (2-6 bytes) |
VarInt (1-5 bytes) | 1 byte |
bool |
[True] or [False] (1 byte) |
1 byte (0/1) | 0 |
Guid |
[Guid] [16 bytes] (17 bytes) |
16 bytes | 1 byte |
DateTime |
[DateTime] [9 bytes] (10 bytes) |
9 bytes | 1 byte |
DateTimeOffset |
[DateTimeOffset] [10 bytes] (11 bytes) |
10 bytes | 1 byte |
TimeSpan |
[TimeSpan] [VarLong] (2-9 bytes) |
VarLong (1-9 bytes) | 1 byte |
decimal |
[Decimal] [16 bytes] (17 bytes) |
16 bytes | 1 byte |
double |
[Float64] [8 bytes] (9 bytes) |
8 bytes | 1 byte |
DTO-heavy payloads with many Guid / DateTime properties benefit the most — easily -10..-20% wire size on top of the existing -22..-33% advantage.
CPU savings
Reader-side: SGen-generated code drops the per-property ReadByte() + IsTinyInt / IsFixStr / switch-case dispatch for primitive properties — direct context.ReadInt32Unsafe() / ReadGuidUnsafe() / etc. calls. Writer-side: drops the WriteByte(typeCode) per primitive. Effect amplifies on payloads with many primitive properties (Small/Medium benchmark cells) — independent of any JIT-vs-AOT measurement variance.
Sketch — opt-in markerless lane, SGen-only
- New wire format flag (header
HeaderFlag_MarkerlessSchema = 0x10or similar) → activates a property-positional lane. - SGen-generated writer for
[AcBinarySerializable]types: per primitive property, emits raw value (no marker). For variable-shape properties (string, complex, nullable, polymorphic) the existing marker-driven path stays. - SGen-generated reader: per primitive property, calls
context.ReadInt32Unsafe()/ReadGuidUnsafe()/ etc. directly. Variable-shape properties keep the marker-read + dispatch. - Heuristic: a property is markerless-eligible if
IsValueType && !IsNullable && type is in {int, bool, byte, short, long, float, double, DateTime, DateTimeOffset, Guid, TimeSpan, decimal}. Anything else (string, list, nested object, nullable) keeps the marker.
Decision points
- Backward compatibility: header flag + version negotiation. Old readers see the flag set and either reject (clean fail) or fall back to marker-driven (if they support both lanes). Default
falsepreserves current wire format. - Schema evolution fragility: the markerless lane is positional, so adding/removing/reordering primitive properties breaks readers compiled against an older schema. Document this clearly — opt-in is for stable schemas only (DTO-frozen API contracts, internal SignalR messages with synchronized client/server SGen). For evolving schemas, marker-driven default stays.
- Coordination with
ACCORE-BIN-T-S5L8(sentinel-length strings): the two could share the "no-marker per-call" infrastructure — markerless string lane uses sentinel-length VarUInt (null/empty/short distinguished by length value).
Acceptance
- Primary:
WireMode = FastAcBinary wire size ≤ MemPack across Small/Medium/Large/Deep AOT benchmark cells (AOT release-publish bench is the canonical measurement) - Wire size: ≥ -10% on DTO-heavy payloads (Guid/DateTime-rich) vs current marker-driven format
- Round-trip on the markerless lane validated on representative DTO shapes (mixed primitive + string + nested object)
- Schema-evolution fragility documented in
BINARY_FEATURES.md(alongside the existingPropertySkip/ default-omission caveat fromACCORE-BIN-I-D9Y2) - Opt-in flag with default
false(preserves marker-driven default; consumers explicitly opt in for frozen-schema scenarios)
ACCORE-BIN-T-V4N4: NativeAOT-specific inlining / codegen audit on hot UTF-8 path
Priority: P2 · Type: Performance · Status: Reverted (2026-05-07) — bench instability made the optimization signal unmeasurable · Related: EncodeUtf8SinglePass, DecodeUtf8SinglePass, WriteStringWithDispatch, Utf8Transcoder SIMD path
Hypothesis: NativeAOT (the benchmark target environment) does not match Tier 1 JIT optimization quality on the UTF-8 hot path, despite [MethodImpl(AggressiveInlining)] hints. Symptoms in 2026-05-05 / 2026-05-06 benchmarks:
- Repeated cell perzisztens 8-11% Compact ≤ MemPack lemaradás (Magyar content + repeated string pattern)
- Compact Ser/Deser cellán mozaikos eredmények run-to-run (4-7/10 cell wins, 3-6 noise/loss bands)
- Methodonkénti Compact gyorsítások a Medium/Large/Deep cellán konzisztensek (-22% to -28% vs MemPack), ami JIT/AOT inlining-eltérésnek tűnik a Repeated-en — ott a
WriteStringWithDispatchshort-lane sokszor hívódik 10× repeated string-en
Suspect mechanisms (ranked by likelihood):
-
AOT inline budget. NativeAOT is more conservative than the Tier 1 JIT in respecting
AggressiveInliningfor large method bodies.EncodeUtf8SinglePass(~190 lines, 4 SIMD path + scalar),DecodeUtf8SinglePass(~120 lines),GetUtf8ByteCount(~120 lines) may exceed the AOT inline budget at hot call sites (WriteStringWithDispatchshort-lane,ReadStringdecode callback). If the AOT compiler emitscall <method>instead of inlining, every iteration of the Repeated 10-string loop pays the call overhead. -
[Intrinsic]IsSupportedconstant folding.Avx512BW.IsSupported,Vector512.IsHardwareAccelerated,Vector256.IsHardwareAccelerated,Vector128.IsHardwareAcceleratedshould constant-fold per host on AOT. Verify via disasm — if any remain runtime checks, every iteration pays the branch cost (3 nestedif-s in each Utf8Transcoder method). -
Vector256.LessThan<ushort>unsigned compare emulation. No nativepcmpltw_unsignedon AVX2; JIT/AOT lowers topminuw+pcmpeqw. Cost amortized over many chars in long content but can dominate on short Magyar runs (KözösCímke~6 runs of 2-3 chars). Less likely if (1) holds — the inlining hit dwarfs the per-instruction emulation cost. -
Method size cascade. The Utf8Transcoder method bodies grew with the V4N3
GetUtf8ByteCountaddition. Adjacent methods in the same source file may have lost inlining at SGen-generated callers due to AOT compilation-unit heuristics (file-locality affects inline cost models on some AOT codegen).
Investigation steps (no code changes — diagnostic phase first):
- NativeAOT publish dump:
dotnet publish AyCode.Core.Serializers.Console -c Release -r win-x64 -p:PublishAot=true dumpbin /disasm <output.exe> > disasm.txt - Locate
EncodeUtf8SinglePass,DecodeUtf8SinglePass,GetUtf8ByteCount,CountUtf8Charssymbols in the disasm - Verify constant folding on
IsSupportedchecks — no run-time CMP/JMP at the path-selector branches; the dead branches eliminated - Verify inlining at
WriteStringWithDispatch/ReadStringcallers — ifcall <Utf8Transcoder.*>instructions remain, inlining failed - Method size inspection — large method bodies hint at inline-eligibility issues; large prologue/epilogue at hot call sites is a tell
- Cross-compare with Tier 1 JIT disasm (run with
DOTNET_TieredCompilation=0+DOTNET_TC_QuickJit=0to force Tier 1, dump the JIT-tier disasm via WinDbg orBenchmarkDotNet's[DisassemblyDiagnoser]) to confirm the gap is AOT-specific rather than algorithmic
Possible fixes (Open until disasm confirms which apply):
- A. Method split —
EncodeUtf8SinglePass→ small dispatcher + per-tier inner methods (each Vector512 / Vector256 / Vector128 / scalar in its own AOT-inline-friendly small method). Same forDecodeUtf8SinglePass. The dispatcher stays small enough to inline at the hot call site; the dead-branch tier methods are never called on a given host. - B.
[MethodImpl(NoInlining)]on cold tiers — paradox tactic that can REDUCE the hot-path code emitted at the call site by preventing the AOT from speculatively considering the dead branches as inlining candidates. - C. Per-target ISA build — if the benchmark environment has a fixed ISA (e.g. AVX2 baseline), use
<IlcInstructionSet>incsprojto constant-fold theIsSupportedchecks at AOT compile time. Alternative: separate per-ISA AOT publish artifacts. - D. Manual hot-path inlining — for the Repeated cell, hand-inline
EncodeUtf8SinglePassshort-string lane intoWriteStringWithDispatchFixStr path (≤31 byte case). Trades code-size for hot-path speed. - E. Algorithm change — if the AOT can't inline the SIMD bodies efficiently, a smaller scalar-only fast path for short strings (≤31 byte) bypassing the SIMD setup might be faster on AOT than on JIT (where Tier 1 is fine with the SIMD path inlined).
Why P2
- Repeated benchmark cell is the canonical witness for the i18n production deploy narrative — public NuGet release narrative depends on parity-or-better against MemPack across all cells (cloud / desktop / mobile / Blazor WASM)
- AOT-specific tuning is high-leverage on the hot path — JIT-only optimizations will not match
- Disasm validation is the prerequisite for any of the fix directions; without it, any change is speculative and risks reintroducing 2c-style regression
Acceptance
- Disasm report confirms (or refutes) inlining + constant-fold hypotheses on the hot UTF-8 path
- If hypotheses confirmed: the chosen fix delivers Repeated Compact Ser+Deser ratio ≤ 1.0 vs MemPack on the AOT benchmark target
- No regression on Small / Medium / Large / Deep cells (or net positive)
- Fix maintains cross-tier SIMD correctness (round-trip tests pass on all UTF-8 content classes); both
Utf8TranscoderTestsand the binary test suite stay green
Trigger
- Pre-NuGet release: i18n claim cannot ship with an 8-11% gap on a representative cell
- Disasm + bench correlation step before any code change (no speculative refactoring)
Resolution
Audit + targeted fix landolt 2026-05-07.
Step 1 — disasm-elemzés (disasm.txt, ~90 MB AOT-publish output):
- ✅
Avx512BW.IsSupported/Vector{N}.IsHardwareAcceleratedconstant-folded — csak 4 runtime check a teljes binary-ben (1 body + 3 call-site, kívül a Utf8Transcoder hot path-tól). Az AOT a target ISA szerint dead-branch-eliminálta. - ✅ Reader tier-marker dispatch (
ReadStringSmall/Medium/Big) inline-olódott aTypeReaderTablelambda-class static init-be — 0 method-call overhead a tier-on. - ⚠️
WriteStringWithDispatchNEM inline-olódott — 3 generic specialization (<ArrayBinaryOutput>,<AsyncPipeWriterOutput>,<BufferWriterBinaryOutput>) különálló method body-val + 14+call <method>instruction az<ArrayBinaryOutput>body-jában (a többi 2 specializációban hasonló volumen). Method size ~190 sor — meghaladja az AOT inline budget-et. - ⚠️
ReadStringUtf8WithCharLenNEM inline-olódott — saját body, sok call-site. - ❓ → ✅
string.Createcallback__DelegateCtor— disasm szerinttest static; jne skip ctorminta = cache-elt static lambda, lazy-init pattern. 0 hot-path overhead (nem per-hívás alloc).
Step 2 — method-split kísérlet (15:13:39 bench):
- Writer split: dispatcher (
[AggressiveInlining]) +WriteStringSmallFast([AggressiveInlining]) +WriteStringDispatchLong([NoInlining]) +WriteStringFastWire([NoInlining]) - Reader split: dispatcher (
[AggressiveInlining]) +ReadStringUtf8WithCharLenCore([NoInlining]) - Bench: regresszió — Small Ser +29.6 pp, Repeated Ser +8.9 pp, Small Deser +16.6 pp.
- Disasm szerint a dispatcher + SmallFast inline-olódott (body symbol eltűnt) — code-bloat: 3 generic spec × ~30-50 SGen call-site × ~45 sor inlined kód = i-cache pressure a Repeated cell hot loop-on. Reader oldali dispatcher NEM inline-olódott (
[AggressiveInlining]hint hatástalan), csak +1 call instruction.
Step 3 — finomított fix (15:29:21 bench, Closed):
WriteStringWithDispatchdispatcher: NO inline hint (a fordítóra hagyva, AOT-ban stabilabb)WriteStringSmallFast:[NoInlining](code-bloat eltünt — call-overhead-tel marad, de strukturálisan dedikált method)WriteStringDispatchLong+WriteStringFastWire:[NoInlining]cold path (megőrizve)ReadStringUtf8WithCharLen+ReadStringUtf8WithCharLenCoreösszeolvasztva vissza egy methoddá (split nem fizetett, +1 call eltünt)
Bench (15:29:21) Compact vs MemPack arányok:
- Ser: Small 0.915 (-8.5%), Medium 0.989 (≈), Large 0.915 (-8.5%), Repeated 1.019 (≈), Deep 0.981 (-1.9%) → 5/5 cell paritás-vagy-jobb
- Deser: Small 1.101 (+10.1%), Medium 0.953 (-4.7%), Large 0.894 (-10.6%), Repeated 0.962 (-3.8%), Deep 0.899 (-10.1%) → 4/5 cell win, csak Small +10%
- Wire: 5/5 cell -8% to -11% kisebb mint MemPack
Tanulság:
- AOT-ban a
[AggressiveInlining]nem garantált — a Writer dispatcher + SmallFast inline-olódott (code-bloat), de a Reader dispatcher NEM (hint hatástalan). A fordítóra bízás (no hint) stabilabb. - Method-split nem mindig nyer — a túl-aggresszív inline-olás code-bloat-ot okozhat (i-cache pressure), különösen sok SGen call-site mellett.
- A
__DelegateCtorcache-elt —string.Createcallback nem hot-path overhead-forrás. - Strukturális struktúra megőrizve:
WriteStringDispatchLongésWriteStringFastWirekülön cold methodok (későbbi célzott optimalizációhoz alapot ad).
Maradék gap: Small Deser +10% — kis abszolút érték (~1 µs), nem release-blocker. A ReadStringUtf8WithCharLen body méretes (single method ~15 sor + lambda-state), AOT inline-budget határán. Tovább optimalizálható a V4N2 vagy W2C8 sprint-ben.
Reverted (2026-05-07)
A V4N4 method-split — mind a 15:13:39 (AggressiveInlining) regressziós verzió, mind a 15:29:21 (NoInlining-on-SmallFast) finomított verzió — visszavonva. A subsequent benchmark futtatások (15:29:21 → 15:56:54 → ...) drasztikus run-to-run varianciát mutattak ugyanazon kódon: az AOT-codegen file-locality / inline-cost-modell mérés-érzékeny a Utf8Transcoder.cs body-méret változásaira, és a noise-floor a method-split feltételezett +1-3% Ser nyereségét eltakarja.
A revert visszaállítja a WriteStringWithDispatch egy-method állapotot (matches 09:39:09 baseline). A megőrzött elemek:
- A irány packed-header store-ok (
Unsafe.WriteUnaligned<ushort/uint/ulong>Small/Medium/Big tier-on) — instruction-level optimalizáció, nem érintett az AOT-variance miatt - Overflow guard (
O7G2—ThrowStringTooLong) — defensive, különálló feature
A V4N4 audit konklúziója változatlan érvényes (constant-fold OK, reader tier-readers inline-olt a TypeReaderTable lambda-class static init-be, __DelegateCtor cache-elt). Az AOT inline-pressure-elemzés továbbra is releváns dokumentáció — csak a method-split mint fix nem volt mérhető-positív.
Tanulság: bench-driven optimalizáció csak akkor érvényesíthető, ha a noise-floor < a várható signal. AOT-on a bench-zaj jelentős (~5-15 pp run-to-run), ami a +1-3% perf-claim-eket eltakarja. Profile-vezérelt optimalizáció (CPU-profile + flame-graph + code-cache miss measurement) lenne a következő lépés, ha az inlining-pressure érdemi gap-ként marad.
Re-evaluable as of 2026-05-07 per ACCORE-BIN-T-D9X3 — bench stabilization removes the noise-floor that made the original signal unmeasurable; retest before any code change.
Obsoleted (2026-05-08) by ACCORE-BIN-T-K7M3 — the writer hot path no longer calls the custom EncodeUtf8SinglePass at all (WriteStringWithDispatch was switched to Utf8.FromUtf16 BCL). The "AOT method-split / inlining audit" target (Utf8Transcoder body method-size in NativeAOT inline budget) is moot — the BCL Utf8.FromUtf16 is a single static method with its own AOT-friendly inline footprint, and the audit's hypothesis space (Vector256 IsSupported constant-fold, lambda delegate cache) was correct for the prior code but no longer applies. The V4N4 disasm methodology remains a valid technique for future investigations of generic specialization / inline failures, but the specific hot-path target it analyzed is gone.
ACCORE-BIN-T-L9Y3: FixArray marker tier — short-list count encoded in marker
Priority: P3 · Type: Wire-format optimization · Status: Open · Related: Array (66) marker, VarUInt itemCount, ACCORE-BIN-T-H2Q6 marker reservation
Analog to FixStr — short list count (0-15) encoded in marker, eliminating the VarUInt itemCount byte for typical DTO collections (Tags, Categories, Items, Properties, Variations, etc. — any list whose size statistically lands in the 0-15 range).
Wire format
Current: [Array marker:1][VarUInt itemCount][items] — header 2-6 byte
FixArray: [FixArrayBase + N marker:1][items] — header 1 byte (N = item count, 0-15)
Writer dispatch (in WriteArray / scan-pass list-writer equivalents):
itemCount ≤ 15→FixArrayBase + itemCountmarker (1 byte total header)itemCount > 15→ existingArraymarker +VarUIntcount (2-6 byte total header)
Marker reservation
16 marker values pre-reserved in the post-H2Q6 marker layout (see ACCORE-BIN-T-H2Q6 "Marker address space reservation" table). The reservation guarantees that activating FixArray does NOT require another wire-format-version bump after H2Q6 lands at v3 — producers opt in to emit FixArray markers within the same v3 envelope, consumers extend their dispatch to decode them.
Activation steps when implementing:
- Allocate
FixArrayBase(16 contiguous values from the H2Q6-freed range) - Add
IsFixArray(byte marker),DecodeFixArrayCount(byte marker),EncodeFixArray(int count)helpers inBinaryTypeCode.cs - Writer: branch in
WriteArrayand equivalent ScanPass list-writers, emit FixArray forcount ≤ 15 - Reader: extend marker dispatch in
ReadValue/SkipValue/ReadArray - SGen: regenerate readers/writers with
IsFixArraydispatch in the array-typed property paths - Round-trip tests for boundary
itemCountvalues: 0, 1, 14, 15, 16, 17 (last tier transition)
Why P3
- Wire saving: -1 byte per short list. Realistic per-cell estimates:
- Repeated (10 OrderItem, ~50 list overall): ~50 byte / 28 KB = ~0.18% wire reduction (marginal)
- Large (5×5×5×10 nested, ~6000 list): ~6 KB / 118 KB = ~5% wire reduction ✓
- Medium: ~500 byte / 21 KB = ~2.4% wire reduction
- Deep (2×4×4×8 nested): similar to Medium, ~2-3% wire reduction
- CPU saving: marginal (~1-2 ns/list —
VarUIntshort-loop replaced by 1-byte marker decode). NOT a hot-path mover for the current Repeated-cell baseline gap. - Release-narrative value: complements the post-H2Q6 wire-size advantage, particularly on deep-nested structures (Large benchmark). Sharpens the "smallest AND fastest" claim once the CPU gap closes via V4N2 Phase 3 + V4N4.
Why not P2/P1 — and why not now
- The current
2026-05-06_13-10-30.LLMbaseline's primary problem is CPU (Compact +5-25% slower than MemPack on every cell), NOT wire size. FixArray addresses wire size, marginal CPU. - Activation after H2Q6 + V4N2 Phase 3 + V4N4 is the natural sequence: CPU gap closes first, then wire-saver features sharpen the release narrative.
- The marker reservation lets us defer activation indefinitely without losing the address-space slot.
Acceptance
- 16 marker values aligned in
BinaryTypeCode.cs(FixArrayBase..FixArrayMax) withIsFixArray,DecodeFixArrayCount,EncodeFixArrayhelpers - Writer + reader dispatch with boundary tests (count = 0, 1, 14, 15, 16, 17)
- SGen-regenerated readers/writers correctly dispatch via
IsFixArrayfor array-typed properties - Round-trip tests pass, no Ser/Deser regression vs current
Arraypath - Wire-size benchmark: ≥-2% on Medium, ≥-3% on Deep, ≥-4% on Large, no regression on any cell
- Documentation update in
BINARY_FORMAT.md(new marker range + dispatch rules)
Trigger
- After
ACCORE-BIN-T-H2Q6lands (marker reservation must be active first) - After CPU gap closes (V4N2 Phase 3 + V4N4) — wire-saver value clearer once "fast" is settled
- Pre-NuGet release housekeeping for the wire-size narrative (along with
S5L8/S2X9if their scope justifies)
Future extension (not part of this entry)
FixDictanalog — same pattern forDictionarymarker (67) withkvCount0-15. Worth considering only if a benchmark workload demonstrates dictionary-heavy structures; the current bench data (Order DTOs) does not. Defer until evidence.FixArray 0-31— wider count range (32 markers). Marginal additional saving (16-31 elem list-ek ritkák); would consume nearly all freed marker space, leaving no slack forS5L8/S2X9. Reject unless evidence warrants.
ACCORE-BIN-T-S6F2: Shift-mentes Small fast path in WriteStringWithDispatch
Priority: P3 · Type: Performance · Status: Reverted (2026-05-07, with V4N4 method-split) · Related: WriteStringWithDispatch, BinaryTypeCode.StringSmall, ACCORE-BIN-T-V4N4
The H2Q6 writer's post-encode tier choice runs a 3-way switch (bytesWritten ≤ 255 → StringSmall, ≤ 65535 → StringMedium, else StringBig) and a header-write switch (3 / 5 / 9 byte) for every non-ASCII string. On the Repeated benchmark cell (Magyar content, ~10-15 char strings dominant) 99%+ of writes resolve to StringSmall — the 3-way switch decision is statistically determinate from charLength ≤ 63 alone (worst-case charLength * 4 ≤ 252 ≤ 255 ⇒ Small tier guaranteed).
A specialized fast path for charLength ≤ 63 could eliminate:
- The
int actualHeader; byte tierMarker;runtime-resolved variables - The 3-way
bytesWrittenswitch - The 3-way
actualHeaderheader-write switch - The
shift = reserveHeader - actualHeadercompute (always 0 in this branch)
Sketch:
if (charLength <= 63)
{
EnsureCapacity(3 + charLength * 4);
var savedPos = _position;
var encodeStart = savedPos + 3;
var bytesWritten = Utf8Transcoder.EncodeUtf8SinglePass(value.AsSpan(), _buffer.AsSpan(encodeStart, charLength * 4));
if (bytesWritten == charLength) { /* ASCII override — FixStrAscii inline */ }
else
{
// StringSmall — 0 shift, inline header write (constant-folded)
_buffer[savedPos] = BinaryTypeCode.StringSmall;
Unsafe.WriteUnaligned<ushort>(ref _buffer[savedPos + 1],
(ushort)(charLength | (bytesWritten << 8)));
_position = savedPos + 3 + bytesWritten;
}
return;
}
// charLength > 63 → fall through to existing post-encode tier dispatch
Why P3
- Repeated cell hot path benefit (~99% of writes on Magyar content are charLength ≤ 63)
- Estimated +1-3% Ser improvement on Repeated/Medium cells (where short non-ASCII strings dominate)
- Constant-folded tier choice + inline header write — no branch overhead vs. the generic post-encode path
- Trade-off: ~30 lines of duplicated specialized code; the generic post-encode path remains for charLength > 63 long-string scenarios
Acceptance
WriteStringWithDispatchSmall fast path emits identical wire bytes as the generic path forcharLength ≤ 63(round-trip parity)- Benchmark on Repeated/Medium cells shows ≥ 1% Ser improvement vs. post-A-direction baseline (
2026-05-07_09-39-09.LLMor later) - No regression on Large/Deep cells (long-string path untouched)
- Round-trip tests pass on the boundary
charLength = 63andcharLength = 64cases
Trigger
- After A-direction (header pack-write) bench result is conclusive
- Pre-NuGet release if the Repeated cell Compact-vs-MemPack Ser ratio still has measurable headroom
Resolution
Integrált megvalósítás ACCORE-BIN-T-V4N4 keretében (2026-05-07): a WriteStringWithDispatch 4-method-os split egyik tagja a WriteStringSmallFast — pontosan az S6F2 ide illeszkedő fast path. A 0-shift non-ASCII branch garantált (charLength ≤ 63 ⇒ bytesWritten ≤ 252 ≤ 255 ⇒ Small tier biztos, reserveHeader = actualHeader = 3).
Az inline-stratégia tanulsága (a V4N4 disasm-ből): a WriteStringSmallFast [NoInlining] jelölést kapott a végleges verzióban — az [AggressiveInlining] kísérlet code-bloat-ot okozott (3 generic spec × 30+ SGen call-site × inlined body = i-cache pressure a Repeated cell hot loop-on, +29.6 pp Ser regresszió a 15:13:39 bench-en). A [NoInlining]-tal az S6F2 logika érvényesül (constant-folded tier choice, 0 shift), csak +1 call instruction overhead-tel.
Bench (15:29:21): Compact Ser 5/5 cellán paritás-vagy-jobb vs MemPack (Small -8.5%, Medium -1.1%, Large -8.5%, Repeated +1.9%, Deep -1.9%). Az S6F2 várt +1-3% Ser-javulás teljesült Small/Large cellákon, a Repeated/Deep paritás-szerű (a +1 call overhead kompenzálja a fast-path nyereséget rövid Magyar string-eken).
Re-evaluable as of 2026-05-07 per ACCORE-BIN-T-D9X3 — together with the parent V4N4 method-split, the Small fast path is re-testable now that bench stabilization removes the noise-floor; retest before any code change.
ACCORE-BIN-T-W2C8: WASM string-cache H2Q6 maximalizálás (ReadStringUtf8Cached MISS path)
Priority: P2 (WASM target) / P3 (otherwise) · Type: Performance · Related: BinaryDeserializationContext.Read.cs::ReadStringUtf8Cached, ReadStringUtf8WithCharLen, Utf8Transcoder.DecodeUtf8SinglePass
H2Q6's primary win is 1-pass decode on the reader side: tier markers carry both charLen and utf8Len, so the reader allocates the target string with the known char count and decodes in a single pass via string.Create(charLength, ..., DecodeUtf8SinglePass). This eliminates the CountUtf8Chars Pass 1 — the headline V4N3/H2Q6 win.
The WASM string-cache path bypasses this win. When _useStringCaching is true (Blazor WASM target), ReadStringUtf8WithCharLen dispatches to ReadStringUtf8Cached(byteLength) for short strings. On cache HIT, the cached instance is returned (zero decode — already optimal). On cache MISS, the current ReadStringUtf8Cached falls back to Utf8NoBom.GetString(slice) — the BCL kétpasszos UTF-8 decoder. The H2Q6 1-pass decode benefit is lost on every cache MISS.
Per-cell impact estimate on a WASM workload with hot-path strings (typical Blazor SignalR DTO traffic):
- Cache HIT rate ~30-50% on repeated property names + tags + categories
- Cache MISS rate ~50-70% on first occurrences + unique values
- MISS path =
Utf8NoBom.GetStringBCL call (virtual dispatch + EncoderFallback overhead) instead ofstring.Create(charLength, ..., DecodeUtf8SinglePass)
Implementation outline
ReadStringUtf8Cached accepts both charLength and byteLength (or just compute charLength from the cache check / decode result). Cache HIT: cached.Length == charLength invariant check (UTF-16 char count, not UTF-8 byte count) + ASCII verification. Cache MISS: replace Utf8NoBom.GetString(slice) with string.Create(charLength, (Buffer, Pos, Len), static (chars, state) => DecodeUtf8SinglePass(state.Buffer.AsSpan(state.Pos, state.Len), chars)).
Cross-check: the existing ComputeStringHashFull(slice) and VerifyAsciiUtf8Match(cached, slice) operate on the raw UTF-8 bytes — these stay unchanged. Only the MISS-side string materialization needs the H2Q6-aware refactor.
Why P2 (WASM-target) / P3 (otherwise)
- The non-WASM benchmark host (x64) doesn't enable
_useStringCachingby default, so this optimization is invisible on the current bench - On Blazor WASM, all interning + repeated-string-cached deserialization currently pays the BCL decode tax on cache MISS
- Estimated +5-15% Deser improvement on WASM workloads with significant cache MISS rate
- Direct extension of the H2Q6 win to the WASM execution profile
Acceptance
ReadStringUtf8Cachedcache MISS path usesstring.Create(charLength, ..., DecodeUtf8SinglePass)— no BCLUtf8NoBom.GetStringon MISS- Round-trip tests pass on cached + uncached short-string scenarios across all UTF-8 content classes (ASCII / Hungarian / CJK / emoji)
- WASM-target benchmark (Blazor profile) shows ≥ 5% Deser improvement vs. pre-W2C8 state on a representative hot-string-heavy DTO workload
- Cache HIT path performance unchanged (already optimal — no decode)
- Cache eviction / capacity behavior unchanged
Trigger
- Pre-NuGet release if Blazor WASM is a primary supported scenario in the release narrative
- Or: when a WASM-fókuszú benchmark workload becomes the active perf measurement target
ACCORE-BIN-T-F3W6: Dedicated FastWire string marker (split mode-shared StringSmall)
Priority: P3 · Type: Performance · Related: WriteStringWithDispatch FastWire branch, ReadStringSmall FastWire branch, BinaryTypeCode.StringSmall, H2Q6 marker reservation
The H2Q6 marker layout currently shares StringSmall (=91) between Compact and FastWire modes:
- Compact emits
[91][charLen:8][utf8Len:8][UTF-8 bytes] - FastWire emits
[91][VarUInt charCount][UTF-16 raw bytes]
The reader dispatches on context.FastWire inside ReadStringSmall. Correct (the deserializer's mode is fixed per operation), but the mode-shared marker forces runtime branching at hot points:
- Writer:
if (FastWire)at the top ofWriteStringWithDispatchruns on every string write — runtime check on a path-dominant (Compact) call site - Reader:
if (context.FastWire)insideReadStringSmallruns on every short non-ASCII string deserialization — Compact-side waste - SGen template: every regenerated reader contains the FastWire-aware
case StringSmall:block (more code per type, larger AOT binary) - JIT/AOT inlining: the larger
WriteStringWithDispatch/ReadStringSmallmethod bodies may exceed inline budgets at hot call sites — particularly under NativeAOT
A dedicated StringFastWire marker (one value from the H2Q6-freed 106-134 range — proposed allocation: 131) splits the path:
- Compact stays on
StringSmall(=91) →ReadStringSmallbecomes Compact-only (noif (FastWire)branch, smaller method body) - FastWire uses new
StringFastWire→ dedicatedReadStringFastWirereader, FastWire-only logic - Writer's FastWire branch emits
StringFastWireinstead ofStringSmall
Wire format compatibility
The marker swap is internally consistent within the v3 envelope — producers that opt in to the dedicated FastWire marker emit it; readers expanded to handle both StringSmall and StringFastWire (transitional). Once all producers emit the dedicated marker, the old mode-shared dispatch in ReadStringSmall can be removed.
Why P3 — "minden apró % számít"
- Estimated +0.5-1% Ser (writer branch elimination on Compact path)
- Estimated +0.5-1% Deser (reader smaller method body, better JIT/AOT inline-eligibility on Compact path; FastWire reader gets a tight dedicated path too)
- Compounds with other micro-opts across the hot path — small percentages add up
- Marker-space cost: 1 reserved value consumed (general-reserve count drops from 4 to 3 in the H2Q6 reservation table)
- Risk: low — mechanical split; round-trip tested against both wire-format variants
Implementation outline
BinaryTypeCode.StringFastWire = 131constant + helper updates (IsStringrange check + dispatch)WriteStringWithDispatchFastWire branch emitsStringFastWire(wasStringSmall)- New
ReadStringFastWire<TInput>static reader —[VarUInt charCount][UTF-16 bytes]decode, no Compact-mode branching ReadStringSmall<TInput>simplified — Compact-only, dropsif (context.FastWire)branchTypeReaderTable[StringFastWire]registrationSkipValuecase StringFastWire:— same skip layout asStringSmallFastWire branch (charCount VarUInt + 2 × charCount bytes)- SGen template
EmitReadString— newcase StringFastWire:block (FastWire-only branch);case StringSmall:simplified to Compact-only - Round-trip tests: separate FastWire and Compact wire format coverage
Acceptance
- Round-trip parity on both Compact and FastWire wire formats (existing tests pass)
- Benchmark on FastWire mode shows ≥ 0.5% improvement vs. mode-shared baseline
- Compact mode shows no regression (likely marginal gain from simpler
ReadStringSmall) - AOT-published binary shows reduced generated reader size per
[AcBinarySerializable]type (one less case-block + branch) - Marker-space documented:
BinaryTypeCode.csreservation comment + H2Q6 entry's reservation table updated to reflect the F3W6 allocation
Trigger
- Pre-NuGet release if every measurable percentage point on the Compact hot path matters for the "fastest" narrative
- Or: when the Compact/FastWire branch profile shows up in a NativeAOT inlining audit (
ACCORE-BIN-T-V4N4)
Roll-back fallback
If a future marker-space crunch arises (additional H2Q6 tiers, new compression markers, etc.), F3W6 can be reverted by switching the writer back to emitting StringSmall on FastWire and re-introducing the mode-shared dispatch in ReadStringSmall. The original design is correctness-equivalent — the dedicated marker is purely an optimization. If marker gondunk lesz, kivesszük.
ACCORE-BIN-T-B1D5: BenchmarkDotNet release-quality measurement project
Priority: P2 · Type: Tooling / release-narrative · Status: Open · Related: AyCode.Core.Serializers.Console (existing custom bench), NuGet release-narrative
The current AyCode.Core.Serializers.Console is a hand-rolled microbenchmark — fast dev-iteration loop (30-90s per run, custom markdown output, internal TestDataSet structure). It serves the inner optimization cycle well, but is not industry-standard for the public NuGet release narrative.
A parallel BenchmarkDotNet-based project would close that gap:
- Industry-standard credibility: BenchmarkDotNet is the canonical .NET benchmarking framework — MemoryPack, MessagePack, System.Text.Json all use it for their published numbers. AcBinary results expressed in BDN format are directly comparable to MemPack's own release notes.
- Statistical rigor: outlier detection (Tukey's fences), interquartile range, confidence intervals, multi-process iteration runs. The current custom bench reports median-of-5; BDN reports the full distribution + variance band — the difference between "looks fast on my machine" and "demonstrably fast under controlled conditions".
- NuGet release surface: BDN markdown tables drop straight into release notes / blog posts / NuGet
README.md/BINARY_FEATURES.md"Performance vs MemoryPack" section. GitHub-friendly format, screenshot-friendly, reviewer-credible. - Diagnostic-plugin integration:
[MemoryDiagnoser]— allocation per iteration (already a hot question for the Repeated cell)[EventPipeProfiler]— CPU profile collection during the bench run, exportable to speedscope flame-graph[DisassemblyDiagnoser]— per-method disasm dump, parallel to the manualdumpbinworkflow used in V4N4[ThreadingDiagnoser]— context switches, lock contention (relevant if pool-contention shows up under load)
- Multi-runtime / multi-job: a single project benchmarks against
RuntimeMoniker.Net90(JIT) andRuntimeMoniker.NativeAot90simultaneously — same-shape table side-by-side. - CI integration potential: BDN result format is machine-readable (JSON/CSV), enabling regression detection on PR diffs (later sprint).
Implementation outline
- New project:
AyCode.Core.Serializers.Benchmark(or.Bdn) — separate csproj for clean BDN dependency isolation. AOT-publishable for the AOT job. - TestDataSet bridge: reuse the existing
TestDataFactory/TestDataSettypes fromAyCode.Core.Tests.TestModelsso the data-shape is identical to the custom bench. - Benchmark class skeleton:
[MemoryDiagnoser] [SimpleJob(RuntimeMoniker.Net90, baseline: true)] [SimpleJob(RuntimeMoniker.NativeAot90)] public class StringSerializationBenchmark { [Params("Small", "Medium", "Large", "Repeated", "Deep")] public string DataSet { get; set; } = "Small"; private object _data = null!; private byte[] _compactWire = null!; private byte[] _mempackWire = null!; [GlobalSetup] public void Setup() { _data = TestDataFactory.Create(DataSet); _compactWire = AcBinarySerializer.Serialize(_data, AcBinarySerializerOptions.FastMode); _mempackWire = MemoryPackSerializer.Serialize(_data); } [Benchmark(Baseline = true)] public byte[] MemPack_Ser() => MemoryPackSerializer.Serialize(_data); [Benchmark] public byte[] AcBinary_Compact_Ser() => AcBinarySerializer.Serialize(_data, AcBinarySerializerOptions.FastMode); [Benchmark] public object? MemPack_Deser() => MemoryPackSerializer.Deserialize<TestOrder>(_mempackWire); [Benchmark] public object? AcBinary_Compact_Deser() => AcBinaryDeserializer.Deserialize<TestOrder>(_compactWire); } - Multi-cell coverage: separate benchmark classes per workload-shape (StringSerializationBenchmark, ObjectGraphBenchmark, NestedDeepBenchmark) — clean grouping in BDN output.
- NativeAOT-job config:
<PublishAot>true</PublishAot>conditionally (mirroringConsoleproject pattern); BDN's NativeAOT job auto-publishes the bench-runner. - Output: GitHub-flavored Markdown export →
docs/BINARY/BENCHMARK_RESULTS.md(or similar), versioned in the repo.
Why P2 (pre-NuGet release)
- NuGet release narrative ("AcBinary fastest AND smallest binary serializer for .NET i18n payloads") needs credible, industry-standard numbers. Custom bench → "trust me, my numbers"; BDN → "here are the variance bands and the methodology".
- Direct comparison surface against MemPack's published BDN numbers (head-to-head on the same framework).
- Diagnostic-plugin integration (
[MemoryDiagnoser]+[EventPipeProfiler]) opens up further targeted optimization work without separate tooling.
Acceptance
- New
AyCode.Core.Serializers.Benchmarkproject compiles + runs cleanly on both JIT (net9.0) and NativeAOT - Reuses existing
TestDataFactory/TestDataSettypes — no test data duplication - Produces a markdown table per workload-shape covering: MemPack baseline + AcBinary Compact + (optionally) AcBinary FastWire, both Ser and Deser
- BDN output saved to
docs/BINARY/BENCHMARK_RESULTS.md(versioned per release) - README.md /
BINARY_FEATURES.mdreferences the BDN-measured performance claim with the methodology link
Trigger
- Pre-NuGet release: when the optimization sprint cluster (V4N2 / W2C8 / etc.) settles and the perf state is release-stable
- Or: when a credibility-sensitive presentation surface emerges (blog post, conference talk, GitHub README)
Coexistence with the custom bench
The custom Console bench is not replaced — it remains the dev-iteration tool (fast feedback loop, 30-90s runs, hand-tuned markdown for chat-paste). BDN is the release-grade bench (3-10 min runs, statistical rigor, NuGet release output). Different tools for different audiences.
ACCORE-BIN-T-P3X7: Profile-driven Compact-mode Ser optimalizációs roadmap (post-K7M3 hot-path analysis)
Priority: P2 · Type: Performance roadmap · Status: Open · Related: ACCORE-BIN-T-K7M3 (BCL UTF-8 transcoder switch — előfeltétele), ACCORE-BIN-T-D9X3 (bench stabilization), ACCORE-BIN-T-S2X9 (markerless schema lane — primitív property-marker már kivezetve a SGen-ben), ACCORE-BIN-T-V4N4 (audit methodológia hivatkozás)
A 2026-05-08 VS Performance Profiler session (4 sec range, AcBinary FastMode Serialize, Latin1Long charset, FastWire mode) konkrét hot-path-decomposition-t adott a K7M3 BCL-csere utáni állapotról. A string-encoding már nem akadály (a Utf8.FromUtf16 SIMD-tuned), a fennmaradó AcBinary-specific overhead azonosítható.
Profile session adatok (Self CPU%)
| Self CPU% | Function | Category |
|---|---|---|
| 39.77% | System.Buffer._Memmove |
Közös MemPack-kel (UTF-16 raw + return-time byte[]-copy) — NEM AcBinary-spec |
| 10.03% | AcBinarySerializer.Serialize<T> |
Top-level (context-acquire, type lookup, return-alloc) |
| 7.48% | TestMeasurementPoint_GeneratedWriter.WriteProperties |
SGen template (legkisebb levél típus, ~12500 hívás Large cellán) |
| 5.31% | WriteStringWithDispatch |
String hot path |
| 3.23% | TestMeasurement_GeneratedWriter.WriteProperties |
SGen |
| 1.66% | WriteVarUIntMultiByteUnsafe |
VarUInt int-property encode |
| 1.10% | TestPallet_GeneratedWriter.WriteProperties |
SGen |
| 0.39% | TestOrderItem_GeneratedWriter.WriteProperties |
SGen |
| 0.32% | SharedUser_GeneratedWriter.WriteProperties |
SGen |
| 0.05% | ArrayBinaryOutput.Grow |
Buffer-grow (ritka, kicsi probléma) |
Total SGen WriteProperties Self CPU: ~12.6% — a leg nagyobb AcBinary-specific surface.
A AcBinarySerializer.Serialize<T> line-szintű drill-down (AcBinarySerializer.cs:312-335):
WriteObject(value, wrapper, context, 0)Total: 28.05% — a teljes serializációs fa (SGen + Writer hot path)context.Output.ToArray(context._buffer, context._position)Total: 47.37% — finalbyte[]-alloc + content-memcpy (= a 39.77%_MemmoveSelf nagy része)
MemPack-összehasonlítás (referenciaként)
A MemPack Serialize<T>(T value) mechanizmus:
[ThreadStatic]writer-state — nincs pool-bérlés, nincs lock, nincs concurrent dictionary lookupReusableLinkedArrayBufferWriter— linked chunk-list (4 KB → 8 KB → 16 KB geometriai); buffer-grow = új chunk hozzáadása, nincs memcpy a régi adatonToArrayAndReset()— végén alloc + chunks → byte[] memcpy (közös overhead az AcBinary-vel)
Az AcBinary AcquireArrayOutputContext(options) pool-bérlés + lineáris byte[] Array.Resize + Output.ToArray(...) — két memcpy-cost (grow + return), de a grow ritka.
Sorrendezett optimalizációs ötletek
A. SGen WriteProperties — ensure-capacity batching (várt: -1-3pp Ser, revíziós becslés)
Jelenlegi SGen-template per-property emit (mindenenkit külön ensure):
context.WriteVarInt(obj.Id); // ensure(5) + write(1-5)
context.WriteByte(BinaryTypeCode.Object); // ensure(1) + write(1)
context.WriteVarInt((int)obj.Status); // ensure(5) + write(1-5)
context.WriteRaw(obj.Weight); // ensure(8) + write(8)
Csoportosított ensure pattern:
context.EnsureCapacity(maxBytesForGroup); // worst-case sum, 1× hívás
context.WriteVarIntUnsafe(obj.Id); // no ensure (csak buffer write)
context.WriteByteUnsafe(BinaryTypeCode.Object); // no ensure
context.WriteVarIntUnsafe((int)obj.Status);
context.WriteRawUnsafe(obj.Weight);
A AcBinarySourceGenerator.cs WriteProperties template-jét kell módosítani:
- Property-listából contiguous primitív csoportok kinyerése (Object/Collection property-knél megszakítva — mély rekurzió, méret nem előre kiszámítható)
- Csoportonként worst-case-size compute compile-time-on (a primitív type-ok mérete fix vagy worst-case ismert)
- Egyetlen
EnsureCapacity(sum)+ bulk*Unsafewrite-ok
*Unsafe írók szükségessége: WriteVarUIntUnsafe már létezik. WriteByteUnsafe, WriteRawUnsafe<T> valószínűleg hozzá kell adni a BinarySerializationContext-hez.
Becslés-revízió (2026-05-08): az eredeti -4-6pp becslés felső volt. Egy EnsureCapacity inline-olva ~1-2 ns/call (a hot path-on a branch-prediction perfekt — sosem jut el a Grow-hoz). 10 property × 1.5 ns = ~15 ns / object megtakarítás batch-eléssel — Latin1Long Large cell 1250 instance × 13 ns = ~16 µs / 120 µs Ser ≈ ~13% felső, de csak az ensure-szám csökkenéséből. A SGen WriteProperties Self CPU 12.6%-a NEM csak ensure-check; tartalmaz HasPropertyFilter branch-check, null-check + depth-check dispatch, Unsafe.As<T> cast, etc. — lásd F. Az ensure-batching önmagában reálisan 1-3pp Ser javulás.
Wire-formátum változatlan, backward-kompatibilis, kis kockázat. Hatás minden cellán mérhető (TestOrder cell-szerkezet ~100+ primitív property per Object-instance).
B. WriteStringWithDispatch Compact ág batch-write (várt: -1-2pp Ser)
A FastWire ágat már K7M3-ban + a 2026-05-08 batch-write fixxel egyetlen ensure + direct-write-ra alakítottuk. A Compact ág ugyanaz a 3-step pattern (post-encode tier-shift CopyTo ha actualHeader < reserveHeader, plus header-write a tier alapján). A Compact ágon is alkalmazható batch-write — egyetlen EnsureCapacity a worst-case-tier-szel + direct header-write a Utf8.FromUtf16 után.
C. Thread-static context (várt: -2-4pp Ser, NAGY refactor)
A AcquireArrayOutputContext(options) pool-bérlés overhead-jét mérsékelheti a MemPack [ThreadStatic] mintázat. A jelenlegi pool-bérlés:
- Pool dictionary lookup (lehet, lock-os)
- Context-state init / reset minden hívásnál
Thread-static cseréje:
- Per-thread cached context, nincs lock
- Context-reset minden hívásnál ugyanaz, de a
stateallokáció egyszer fut
Refactor szempontok:
- A
BinarySerializationContextstate-tárolása nem thread-safe önmagában — pool-bérlés vagy thread-static mind a single-thread haszálatot biztosítja - Az
optionsparaméter érintheti a state-init logikát — multi-options scenárió esetén a thread-static state-t reset-elni kell - Concurrent serialize hívások (több thread egyidejű) — minden thread saját state-tel rendelkezne; nincs cross-thread sharing igény
D. Linked-array buffer chunk strategy (kicsi hatás, NAGY refactor)
A MemPack ReusableLinkedArrayBufferWriter linked chunk-list helyettesíti a lineáris byte[]-grow stratégiát. Buffer-grow = új chunk hozzáadása (no memcpy a régi adaton).
A profile szerint a ArrayBinaryOutput.Grow Self CPU csak 0.05% — a buffer-grow ritkán fut, a default kapacitás elég nagy a Large cell-hez. Kicsi hatás, nagy refactor. Alacsony prioritás.
F. SGen HasPropertyFilter lift-out a WriteProperties method elejére (várt: -2-4pp Ser)
A jelenlegi SGen-template minden property-emit előtt ellenőrzi a property-filter-t:
public void WriteProperties<TOutput>(object value, ...)
{
var obj = Unsafe.As<TestPallet>(value);
if (context.HasPropertyFilter) // ← MINDEN property-en check!
{
var fc_Category = new BinaryPropertyFilterContext(obj, ..., "Category", ...);
if (!context.PropertyFilter!(in fc_Category)) {
context.WriteByte(BinaryTypeCode.PropertySkip);
goto skip_Category;
}
}
if (obj.Category == null) context.WriteByte(BinaryTypeCode.PropertySkip);
else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);
else { context.WriteByte(BinaryTypeCode.Object); ...WriteProperties... }
skip_Category:;
if (context.HasPropertyFilter) { /* same for Inspector */ } // ← újra!
// ... 10× ismétlés property-listán
}
A HasPropertyFilter per-property branch-check TestOrder benchmark workload-on mindig false (a benchmark nem használ property-filter-t). De a check minden property-en lefut — kód-cache-ben benne van, branch-predict ugyan jó, mégis CPU cycle.
Optimalizáció — kétpályás SGen kódgenerálás:
public void WriteProperties<TOutput>(object value, ..., int depth)
{
var obj = Unsafe.As<TestPallet>(value);
if (context.HasPropertyFilter)
{
WritePropertiesWithFilter(obj, context, depth); // ritka path — full per-property check
return;
}
// Fast path — NO filter check anywhere
if (obj.Category == null) context.WriteByte(BinaryTypeCode.PropertySkip);
else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);
else { ... }
// (no skip_Category goto — never needed)
context.WriteVarInt(obj.Id); // primitív, no filter check
// ... rest of properties without HasPropertyFilter check
}
// Külön emit-elt method ritka path-ra:
private static void WritePropertiesWithFilter<TOutput>(TestPallet obj, ..., int depth)
{
// Full per-property filter-aware kód (the current behavior)
}
A AcBinarySourceGenerator.cs-t kell módosítani:
- A
WritePropertiesmethod elején egyetlenHasPropertyFiltercheck - Két különböző code-path emit:
- Fast path (default — no filter): nincs per-property
if (context.HasPropertyFilter)check, nincs filter-context allokáció + lambda-call, nincsgoto skip_X - Slow path (filter aware — separate static method): a jelenlegi viselkedés
- Fast path (default — no filter): nincs per-property
Várt nyereség: a fast path ~10 elimináció / object × 1-2 ns / branch ≈ ~15-20 ns / object. Latin1Long Large cell 1250 instance × 18 ns = ~22 µs / 120 µs Ser ≈ ~18% felső becslés; reálisan 2-4pp Ser javulás (a kód-bloat növekedés és a JIT inlinelés-ráhatás miatt mérséklődik).
Kombinálható az A-val: az A + F együtt 3-7pp javulás célozható meg — a SGen WriteProperties 12.6% Self CPU jelentős csökkenése.
Wire-formátum változatlan, kód-méret kicsivel nő (két path-ot generál minden type-on), de a fast path a JIT-tel jobban inlinelhető.
G. SGen WriteProperties null/depth/object-ref kombinálás (kapcsolt az F-hez)
A komplex (Object) property-knél a 3-ágú dispatch:
if (obj.X == null) context.WriteByte(BinaryTypeCode.PropertySkip);
else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);
else { context.WriteByte(BinaryTypeCode.Object); X_GeneratedWriter.Instance.WriteProperties(...); }
Ez minden komplex property-en fut. Lehetséges optimalizáció: a depth > MaxDepth check egy method-szintű branch-szé alakítás (egyszer ellenőrizni a method elején, aztán a property-szintű ágat egyszerűsíteni). De ez kis hatás és a MaxDepth jellemzően nem érintő (a legtöbb workload-on depth < MaxDepth).
Alacsony prio, F-tel kombinált.
E. WriteVarUIntMultiByteUnsafe (1.66% Self) → fix-int (várható: -1pp Ser, NEM javasolt önmagában)
A WriteVarInt (signed int property-encode, ZigZag + VarUInt) kódolás a SGen-template-ekben gyakori (Id, Status, TrayCount, stb.). A multi-byte ág 1.66% Self CPU.
Fix-int (4 byte) cseréje wire-méret-növekedéssel jár (kis int-eken +3 byte / property), ami a wire-formátum kompaktság-előnyét rontja. Csak ACCORE-BIN-T-S2X9 markerless lane kontextusban érdemes — ahol a property-marker eltávolításával együtt fix-int kicserélése wire-szempontból kompenzálódik.
Közös, NEM AcBinary-spec overhead — nem optimalizálható
A Buffer._Memmove 39.77% Self CPU + a Output.ToArray() 47.37% Total a return-time byte[]-alloc + content-memcpy, ami minden byte[] Serialize(T) hívásnál fut. Mindkét engine fizeti (MemPack ToArrayAndReset() is alloc + memcpy a chunkokból). Az API contract (byte[] Serialize(T)) miatt elkerülhetetlen.
Aki teljesítményt akar, használja a IBufferWriter<byte> overload-ot (AcBinaryBufferWriterBenchmark vs MemoryPackBufferWriterBenchmark apples-to-apples a benchmarkban — mindkét engine ugyanezt csinálja).
Acceptance (per-section)
- A (SGen ensure-batching): Latin1Long FastWire bench AcBinary Ser delta vs MemPack -1-3pp javulás minden cellán
- F (HasPropertyFilter lift-out): Latin1Long Ser delta -2-4pp; A + F együtt SGen
WritePropertiesSelf CPU ≤ 8% (jelenleg ~12.6%) - G (null/depth/object-ref kombinálás): kis hatás, F-tel kombinált
- B (WriteStringWithDispatch Compact batch-write): Latin1Long Compact bench AcBinary Ser delta vs MemPack ≤ +5% minden cellán
- C (Thread-static context):
Serialize<T>Self CPU ≤ 6% (jelenleg ~10%) - D (Linked-array): nem prioritás — buffer-grow Self CPU már ≤ 0.05%
- E (VarInt → fix-int): csak az
S2X9markerless lane sprint kontextusában mérni
Sorrend
- A + F kombinálva — SGen
WritePropertiestemplate átfogó refactor (ensure-batching + HasPropertyFilter lift-out + esetleg G null/depth-combine). Együtt ~3-7pp Ser javulás várt minden cellán. Izolált változtatás csakAcBinarySourceGenerator.cs-en, wire-format változatlan. - B — ~1-2pp javulás, ugyanaz a pattern mint a
K7M3FastWire batch-write - C — ~2-4pp, de NAGY refactor (thread-safety, pool semantics felülvizsgálat)
- D — alacsony prioritás (kis hatás, nagy refactor)
- E — csak
S2X9kontextusban
Trigger
- A + F → most azonnal implementálható; ezek a SGen template-en belül kombinálandók (egyetlen template-átdolgozás kétségtelenül jobb mint külön refactor-körök). Minden továbbai mérés ettől függ.
- B → A+F után, hasonló pattern alkalmazása más writer-helyen
- C → ha a Serialize Self CPU 10% továbbra is dominál A+F+B után
- D, E → opcionális, az A/F/B/C eredmények alapján
ACCORE-BIN-T-Q5T2: Önleíró wire-formátum — duplikált object-marker-ek + UTF-16 string marker (per-type/property encoding choice)
Priority: P2 · Type: Architecture / Performance · Status: Open · Related: ACCORE-BIN-T-P3X7 (profile-driven roadmap — kis-adat slowdown diagnózis), ACCORE-BIN-T-K7M3 (BCL UTF-8 transcoder — előfeltétele), ACCORE-BIN-T-S2X9 (markerless schema lane), ACCORE-BIN-T-V4N2 (UTF-8 SIMD)
A 2026-05-08 design-session során merült fel mint válasz a kis-adat-slowdown problémára és az if (FastWire) / if (UseMetadata) runtime-branch-ek széles jelenlétére. Cél: a wire-mode kivezetése a globális header-ből, per-object/per-property encoding-szabadság attribute-tal, megőrizve a SGen↔Runtime wire-kompatibilitást.
LLM Context (cold-start)
Egy fresh session olvasásához ez a kontextus elég:
Wire-modell: AcBinary két párhuzamos serializációs path-ot futtat — SGen (compile-time generált, [AcBinarySerializable] típusokra) és Runtime (reflection + Expression.Compile). Mindkettő ugyanazt a wire-t produkálja és olvassa (interop garancia, BINARY_SGEN.md "Hybrid Execution Model").
Markerless body: object scope-on belül a primitív property-k (int, long, double, …) közvetlenül írnak a wire-be, marker-byte nélkül. A reader a sorrendet compile-time schema-ból (SGen) vagy OrderedProperties metadata-ból (Runtime) tudja. A wire object-prefix-szel kezdődik (1-byte marker), majd markerless body.
Meglévő object-marker család (AcBinarySerializer.BinarySerializationContext.cs writer-ek + AcBinaryDeserializer.cs reader-dispatch switch):
Object— sima first-occurrenceObjectWithTypeName— polimorf (runtimeType != declaredType)ObjectFullMarkerIId/ObjectFullMarkerAll—RefHandling=IId|Allfirst-occurrenceObjectRef/ObjectRefIId— subsequent (csak ID, NEM duplikálódik — nincs primitív property körülötte)
OPT-OUT minta (jelenlegi konvenció): default SGen flexibilis — minden runtime-branch-et generál (pl. if (context.UseRefHandling)). Class-attribute disable-eli a feature-t → SGen omitti a branch-et → drasztikus optimum. Q5T2 ezt a mintát terjeszti ki encoding-választásra.
Naming-konvenció: PascalCase, suffix-variánsok (Object → ObjectVarUInt, String → StringUtf16). NEM Object_NoZZ, NEM ObjVU.
Motiváció
A jelenlegi AcBinaryOptions.WireMode (FastMode vs Compact) payload-szintű globális flag:
- A kódban sok
if (FastWire) { ... } else { ... }branch (lásdWriteVarInt514. sor,WriteStringWithDispatch,WriteValueNonPrimitive, property-writers) - A fejlesztő nem optimalizálhat granuláris szinten (pl.
[NoZZ]egy hot type-ra, default másnak) - Schema-evolúciós szempontból: ha a szerver attribute-ot változtat egy type-on, a klienseknek (akár régebbi verzió) rekomp nélkül olvasniuk kell az új wire-t
A ACCORE-BIN-T-P3X7 profile-bench mérése szerint a kis-adat slowdown (Latin1Long Small +2.6%, Medium +1.5% AcBinary lassulás MemPack-hez képest) jelentős részben a VarUInt per-call overhead-ből származik (ZigZag shift + multi-byte branch loop). A type-szintű [IntEncoding=VarUInt] attribute-tal a fejlesztő a non-negative property-ket VarUInt-NoZigZag-ra állíthatja → ZigZag shift kiesik, kis-adatra mérhető nyereség.
Wire-formátum design
5 új BinaryTypeCode marker (naming TBD: *VarUInt vagy *NoZZ suffix, implementációkor véglegesítendő):
| Új marker | Cél | Alkalmazási hely |
|---|---|---|
ObjectVarUInt |
Object scope primitive int/long/enum-jai NoZigZag VarUInt encoding-ban | sima object first-occurrence |
ObjectWithTypeNameVarUInt |
Polimorf first-occurrence NoZZ-variánsa | runtimeType != declaredType esetén |
ObjectFullMarkerIIdVarUInt |
RefHandling=IId first-occurrence NoZZ-variánsa |
csak first; subsequent ObjectRefIId változatlan |
ObjectFullMarkerAllVarUInt |
RefHandling=All first-occurrence NoZZ-variánsa |
csak first; subsequent ObjectRef változatlan |
StringUtf16 |
UTF-16 encoded string content (property-szintű) | bárhol egy string property emit-jénél |
Wire-példa:
[ObjectVarUInt marker] ← scope-szintű: int-property-k VarUInt-NoZZ
WriteVarUInt(obj.Id) ← markerless body, encoding a marker alapján
WriteVarUInt(obj.Status)
[String marker] UTF-8(obj.Notes) ← default UTF-8
[StringUtf16 marker] UTF-16(obj.Name) ← property-szintű override
Byte-szintű példa (Order { Id=42, Status=3, Notes="ok" }, class-szintű IntEncoding=VarUInt):
- Default ZigZag wire:
[Object][0x54](VarInt 42 ZigZag:((42<<1)^(42>>31))=84)[0x06](VarInt 3 ZigZag: 6)[String][0x02]0x6F 0x6B - New VarUInt wire:
[ObjectVarUInt][0x2A](VarUInt 42 raw:0x2A)[0x03](VarUInt 3 raw:0x03)[String][0x02]0x6F 0x6B - Body-sorrend és byte-szám változatlan; csak az encoding-szabályok mások. Stringek ugyanúgy markered (UTF-8 default itt). String-encoding override esetén
[StringUtf16][char-count][2-byte-per-char].
A primitive property-k körüli wire markerless marad — a body-encoding-ot az object-marker határozza meg, nem per-property byte. Wire-bloat csak ott van, ahol most is van marker (object-prefix, string-marker).
Attribute design
Object-szintű (mert object-marker is object-szintű):
[AcBinarySerializable(IntEncoding = IntEncoding.VarUInt)]
public class Order { ... }
Property-szintű (csak string-en, mert string-marker is per-property):
public class Order {
[AcBinaryEncoding(StringEncoding.Utf16)]
public string CustomerName { get; set; }
}
Új public API elemek:
AcBinaryEncodingAttribute(target:Class | Property)IntEncodingenum (Default= ZigZag VarInt,VarUInt= NoZigZag)StringEncodingenum (Default= UTF-8,Utf16= UTF-16)AcBinaryOptions.IntEncodingésAcBinaryOptions.StringEncodingruntime fallback opciók
Encoding-választás precedenciája (writer-side)
- Property attribute (legerősebb) — pl.
[AcBinaryEncoding(StringEncoding.Utf16)] - Class attribute — pl.
[AcBinarySerializable(IntEncoding=VarUInt)] AcBinaryOptionsruntime opció — pl.options.StringEncoding = Utf16- Built-in default — ZigZag-VarInt + UTF-8
Szerepkörök és path-ok
| Path | Encoding-választás |
|---|---|
| SGen writer (with attribute) | Compile-time pinned, hard-coded marker + encoding emit (NO runtime branch) — a meglévő OPT-OUT minta (mint RefHandling/Interning disable) |
| SGen writer (no attribute) | Runtime branch a context.IntEncoding/context.StringEncoding option-en — két path generálódik, runtime dönt |
| SGen reader | Marker-dispatch (NEM hard-coded marker-expect — runtime-on dönti el, hogy Object vagy ObjectVarUInt érkezett, és annak megfelelően olvas) |
| Runtime writer (reflection-based) | Reflection-attribute-read + option fallback + default fallback — ugyanaz a precedencia mint SGen-nél |
| Runtime reader | Marker-dispatch (universal — nincs attribute / option használat encoding-döntésre, csak a marker-byte) |
⚠️ SGen reader marker-dispatch KÖTELEZŐ (NEM hard-coded marker-expect). Konkrét scenario amit ez kezel:
Szerver Runtime-mode-ban serializálja
Order-t. AzOrderosztályon a szerver-deploy óta változott az attribute (új deploy hozott[IntEncoding=VarUInt]-ot). Szerver Runtime writer reflection-ből olvassa az új attribute-ot →ObjectVarUIntmarkert emit-el a wire-be.Régi kliens rekomp nélkül kapja a payload-ot. Ha a kliens SGen reader-e hard-coded
Object-marker-expect-tel olvasna → panik / mismatch.Marker-dispatch-szel a kliens helyesen dekódol bármelyik markert, függetlenül attól, hogy a kliens-oldali compile-time
Ordertypebe-n volt-e az attribute.
Ez biztosítja a "server-side attribute-change doesn't break clients" garanciát.
Kompatibilitási garanciák
| Interakció | Eredmény |
|---|---|
| SGen-write (NoZZ attr) → SGen-read | OK (marker-dispatch) |
| SGen-write (NoZZ attr) → Runtime-read | OK (marker-dispatch) |
| Runtime-write (option=NoZZ) → SGen-read | OK (marker-dispatch) |
| Runtime-write (option=NoZZ) → Runtime-read | OK (marker-dispatch) |
| Server-attribute-changed → old client (no recompile) | OK — kliens csak a marker-t olvassa |
| Mixed payload (egyik object NoZZ, másik default) | OK — minden object-marker önálló scope |
Implementációs lépések
BinaryTypeCodeconst-bővítés — 5 új byte-érték (range-allokáció: a meglévő enum szervezése alapján a következő szabad slot-okba). Wire-format spec frissítésBINARY_FORMAT.md-ben.AcBinaryEncodingAttribute+IntEncoding+StringEncodingenum-ok — új fájlok azAyCode.Core/Serializers/Binaries/mappában.AcBinaryOptions.IntEncoding+AcBinaryOptions.StringEncodingopciók hozzáadása (default =Default).WriteStringUtf16/ReadStringUtf16context-helper-ek —MemoryMarshal.Cast<char,byte>direct copy + length-prefix (VarUInt char-count).- Runtime writer reflection —
BinarySerializeTypeMetadatacache:IntEncoding,StringEncoding-per-property flag-ek (attribute-alapján). Encoding-emit a precedencia szerint. - SGen writer template — attribute-feldolgozás
EmitWriteValue-ban: ha attribute → compile-time hard-coded emit; ha nincs → runtime-branch emit acontextoption-en. - SGen reader template —
EmitReadValuemarker-dispatch-szel (object-marker scope-encoding-mode tracking + string-marker per-property dispatch). - Runtime reader update — object-marker dispatch a scope-encoding-state-be (pl.
BinaryDeserializationContext.CurrentIntEncoding), string-marker per-property dispatch. - Cross-mode tesztek — minden write-read kombináció (SGen↔SGen, SGen↔Runtime, Runtime↔SGen, Runtime↔Runtime) minden encoding-kombinációban (default, attr-only, option-only, attr+option, mixed payload).
- Doc:
BINARY_FORMAT.mdwire-format spec,BINARY_OPTIONS.mdúj opciók,BINARY_SGEN.mdprecedencia + szerepkörök táblázat.
Acceptance
- 5 új BinaryTypeCode marker, naming-konvenció dokumentált
AcBinaryEncodingAttribute+ 2 enum + 2 opció extension working- Round-trip teszt minden cross-mode kombinációban zöld
- Wire-bloat default-encoding-on 0 byte (nincs új per-property marker)
- Latin1Long Small bench: AcBinary
[IntEncoding=VarUInt]típuson a slowdown ≤ MemPack +0.5pp (jelenleg +2.6%) BINARY_FORMAT.md/BINARY_OPTIONS.md/BINARY_SGEN.mdszinkronban a wire- és attribute-világgal- A meglévő
WireMode=Fast/Compactdistinction-ek kompatibilisek maradnak (vagy migrálódnak az új encoding-attribute-okra — külön döntés implementációkor)
Trigger / Sorrend
Implementáció ne kezdődjön azonnal — a ACCORE-BIN-T-P3X7 A+F szekciói (SGen ensure-batching + HasPropertyFilter lift-out) előbb mérendő. Ha az A+F már lehozza a SGen WriteProperties Self CPU-t ≤ 8%-ra, és a kis-adat slowdown ettől már ≤ +1pp, akkor ez a Q5T2 entry alacsony prioritásra kerül. Ha a kis-adat slowdown az A+F után is megmarad → Q5T2 implementáció érdemi.
Egyéb prerekvizit: ACCORE-BIN-T-W9F1 (compile-time metadata) szinkronizálás — a Runtime writer reflection-attribute-read-je beleilleszthető a generált metadata-ba, ezzel a runtime path is gyorsabb attribute-alapú encoding-választás-on.
Open kérdések (implementációkor eldöntendő)
- Marker naming:
ObjectVarUInt(semantic, az encoding alapján) vagyObjectNoZZ(rövidebb)? [AcBinarySerializable]-on belül vegyük fel aIntEncodingparamétert, vagy külön[AcBinaryEncoding]attribute legyen object-szinten is (és a[AcBinarySerializable]változatlan)?AcBinaryOptions.WireModejövője: a régiFast/Compactenum migrálódjon az újIntEncoding/StringEncoding-ra (BC-break) vagy maradjon mint shortcut-default?
ACCORE-BIN-T-J8R4: ASCII Ser path — combined store optimization in WriteStringWithDispatch
Priority: P2 · Type: Performance
AcBinarySerializer.BinarySerializationContext.WriteStringWithDispatch ASCII override branch (bytesWritten == charLength, bytesWritten > 31, currently around context.cs:919-930) emits separate marker store + WriteVarUIntUnsafe method-call. The non-ASCII case <= 255 branch uses a combined 4-byte unaligned uint store for marker+charLen+utf8Len header.
Benchmark evidence: AsciiShort charset benchmark (Console.FullBenchmark_Release_2026-05-19_09-55-05) showed Ser median +3.8% (MemPack faster) — regression vs Latin1Long Ser median -2.0%. Repeated-strings cell +13%. The method-call frame to WriteVarUIntUnsafe is the suspected cost.
Quick fix sketch: split the ASCII path on bytesWritten < 128 (VarUInt fits in 1 byte → 2-byte combined ushort header store, no method call) vs >= 128 (current separate-write path, rare on workload). May also benefit from extending to a 4-byte combined store for bytesWritten < 16384 (VarUInt fits in 2 bytes).
Validate on AsciiShort + AsciiLong + Latin1FixAscii (no regression on FixStrAscii path). Cross-check the Repeated +13% cell.
ACCORE-BIN-T-M2L7: Per-mode _typeReaders table — eliminate residual mode-aware ternaries
Priority: P3 · Type: Performance · Related: ACCORE-BIN-T-T8F6 (may subsume this — switch refactor inlines mode-dispatch on the StringSmall case directly)
After the ReadStringSmall → ReadStringSmallCompact / ReadStringSmallFastWire split, 3 call sites still inline FastWire ? FW : Compact ternary:
AcBinaryDeserializer.cs—Readers[BinaryTypeCode.StringSmall]lambda (TypeReaderTable static init)AcBinaryDeserializer.cs— cross-type populatePropertyAccessorType.Stringcase (~line 1055)AcBinarySourceGenerator.GenReader.cs—EmitReadStringswitch caseStringSmallemit (~line 243)
Sketch: a _typeReaders instance array on BinaryDeserializationContext<TInput> initialized at Reset/Initialize time from one of two static tables (TypeReaderTable<TInput>.Readers vs lazy-built FastWireReaders). The FastWire variant differs from the default only in the StringSmall slot (clone of the default + 1 overwrite). Hot path: context._typeReaders[typeCode] — zero ternary.
Sibling SGen-emit pattern: emit context._typeReaders reference if the call site is in a switch case where the runtime mode can vary (Dictionary key/value, runtime populate). Direct ReadStringSmallCompact() stays in the SGen property hot-path (caller already short-circuits FastWire on a separate ag).
Mode-check cost: 1 reference assignment per Initialize call vs 1 branch per StringSmall marker dispatched. Net win on string-heavy workloads.
ACCORE-BIN-T-T8F6: Replace TypeReaderTable delegate-array with switch dispatch in ReadValue
Priority: P2 · Type: Performance · Related: ACCORE-BIN-T-M2L7 (subsumes — mode-dispatch becomes a case-local branch), BINARY_STRICT_SGEN.md (Phase 2 SGen-emit simplification — same direct-call philosophy)
AcBinaryDeserializer.cs:74-144 builds a 256-slot Func<BinaryDeserializationContext<TInput>, Type, object?>[] dispatch table per TInput specialization. Every ReadValue marker dispatch is Readers[typeCode](ctx, type) — indirect call via delegate-instance, non-inlinable for JIT, opaque for NativeAOT (IL_calli lower).
No concrete reason for the table over a switch (reviewed: generic-TInput specialization works either way; FixObj 0-63 / FixStrAscii 135-166 ranges become case >= 0 and < 64: slot = typeCode trivially; no consumer-extensibility pattern; no per-context override; no multi-version dispatch; cross-type populate doesn't share the table). The pattern is likely historical — first prototype that worked, never refactored.
Sketch: ReadValue body becomes switch (typeCode) { case BinaryTypeCode.Object: return ReadObject(ctx, type); ... }. Range cases case >= 0 and < BinaryTypeCode.SlotCount: ... for FixObj, case >= BinaryTypeCode.FixStrAsciiBase and <= BinaryTypeCode.FixStrAsciiMax: var length = ...; ... for FixStrAscii. The StringSmall case inlines the mode-aware ternary directly (context.FastWire ? ReadStringSmallFastWire() : ReadStringSmallCompact()) — eliminates M2L7's separate lazy-clone need.
Expected gain: ~3-7 ns/dispatch (delegate-invocation overhead eliminated), branch-predictor-friendly (frequent markers as first cases), inline-friendly for small case-bodies (Null → return null). NativeAOT publish size benefit: ~128 lambda-class metadata entries removed per TInput (×4 = ~512 closure objects).
Mérni mielőtt belevágunk: mikrobenchmark a ReadValue switch-variant vs delegate-variant 100K-iter loop-pal vegyes marker-mix-szel. Ha ≥2 ns/dispatch gain → érdemes; ha nincs különbség → marad ahogy van.