AyCode.Core/AyCode.Core/docs/BINARY/BINARY_TODO.md

175 KiB
Raw Blame History

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.md and BINARY_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

  • BufferWriterBinaryOutput rewritten 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 uses BitOperations.Log2 + single Unsafe.WriteUnaligned<uint> store for the 2/3/4-byte tiers (branch count reduced from 4 to 1). See BINARY_FORMAT.md Variable-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.md Variable-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:23 NRE in chunked send via OrderItemPallet → 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 (asserts BinaryTypeCode.PropertySkip presence 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 through EmitDirectDictionaryWrite (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 EnsureAvailable checks 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:

  1. SGen-emit inlines marker decode/encode at every property emit site (StringInternFirstSmall, Object/ObjectRefFirst/Null/ObjectRef/FixObj-slot dispatch, etc.).
  2. Runtime TypeReaderTable dispatches via static (ctx, _) => ReadXxx(ctx) lambdas to per-marker static helpers in AcBinaryDeserializer.
  3. Cross-type populate (PopulateProperty fallback) 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).
  • ReadObject family — careful: this branches on targetType and 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-dasm spot-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*Feature flags. Verified by spot-checking generated *.g.cs files: an EnableInternStringFeature=false type's reader does NOT contain StringInternFirstSmall / Medium / StringInterned cases.
  • 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 / BinaryDeserializeTypeMetadata get 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_propertyHashes static fields (hot-path access stays static, zero indirection) and passes the same references to the metadata — single source of truth, no duplicate computation.
  • ModuleInit registers both the writer/reader and the pre-built metadata into a GeneratedMetadataRegistry. GetWrapperSlow consults this registry first, falling back to the reflection-based MetadataFactory for runtime-only types.
  • Lazy RuntimeInit() pattern for Expression.Compile property accessors:
    • TypeMetadataBase gets volatile bool _runtimeInitialized + internal void RuntimeInit() (idempotent, no lock needed).
    • GetWrapperSlow calls metadata.RuntimeInit() only when wrapper.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.
  • volatile guards the flag; multiple contexts may race into RuntimeInit, 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.Compile on 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 / finally audit on hot path — On .NET 9 (project's minimum target), JIT silently refuses to inline any method containing an EH region (AggressiveInlining is ignored). [.NET 10 partially lifts this for same-module try-finally — see dotnet/runtime#112998, merged 2025-03-20 — but catch, 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 / ScanValueGenerated and any helper called from generated WriteProperties for accidental try/finally / using blocks.
    • SGen output template (AcBinarySourceGenerator.cs): generated WriteProperties / ScanObject / ScanForDuplicates / ReadObject / ReadProperties MUST stay straight-line. Future feature additions ([CustomSerializer] / [CustomDeserializer] hooks, OnSerializing / OnDeserialized callbacks, validation attributes, rented-buffer using blocks) are tempting candidates for try/catch/finally — emit them in separate cold helpers, never inline into the generated hot method. A single accidental try block in WriteProperties makes 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. See BINARY_IMPLEMENTATION.md Rule #3 (Inlining barriers) and BINARY_SGEN.md (SGen Output Constraints).
  • stackalloc size discipline on hot path — On .NET 9, methods containing localloc (any C# stackalloc) historically blocked inlining. Modern .NET allows inlining only for fixed-size stackalloc ≤ 32 bytes outside loops (see dotnet/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 a stackalloc is non-inlinable. Combined with try/finally for ArrayPool.Return cleanup, the method is doubly non-inlinable on .NET 9. Plan accordingly: keep stackalloc-using helpers as deliberate cold call-frames, not as AggressiveInlining candidates.
  • 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 named Id is 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 in Convention mode 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, no where T : IId<TKey> constraints in the serializer surface).
  • Wire format unchanged.
  • Existing consumers using IId<T>-implementing types still work transparently in Convention mode (their Id property is detected via convention).
  • New consumers can use plain POCOs with no AyCode.Interfaces dependency.
  • IdDetectionMode exposed on AcBinaryOptions (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):

  1. Single rename, semantic-neutralBufferWriterChunkSizeBufferWriterBufferSize or BufferWriterPageSize. Minimal API surface change, single-property semantics preserved. Downside: still slightly off for the streaming path where there IS chunked framing.
  2. Two-property splitInternalBufferSize (universal: how much data accumulates before Advance/Grow) + StreamingChunkSize (only meaningful for AsyncPipeWriterOutput; separate knob, defaults to InternalBufferSize). Cleanest semantics, most ceremony, slightly more options to document.
  3. Single rename, streaming-honest — Keep as BufferWriterChunkSize but 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 (ArrayBinaryOutput ctor, BufferWriterBinaryOutput ctor, AsyncPipeWriterOutput ctor)
  • AcBinaryDeserializer.cs × 1 site (receive-side initial capacity derivation)
  • AsyncPipeReaderInput.cs — XML doc cross-refs
  • BINARY_WRITERS.md, BINARY_TODO.md (this entry), BINARY_ISSUES.md (line 151 — already lists BufferWriterChunkSize among the struct-mutation issue's affected setters)
  • Consumer-side: AyCode.Services/SignalRs/AcBinaryHubProtocol.cs ctor mutates _options.BufferWriterChunkSize = options.BufferSize; — see BINARY_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 AcBinaryHubProtocol updated; if Option 2 is chosen, the protocol uses StreamingChunkSize (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-generic Type-based variant.
  • Body: MemoryMarshal.TryGetArray(data, out var seg) → array-backed path delegates to Deserialize<T>(seg.Array!, seg.Offset, seg.Count, options) (zero-copy). Non-array-backed fallback (rare — custom MemoryManager<T> with native memory) copies into a pooled byte[].
  • Memory<byte> overload trivially delegates to the ReadOnlyMemory<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[] and ReadOnlySequence<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:

  1. ref struct SpanBinaryInput + interface bump to support ref byte buffer / int length fields. Pure zero-copy from any span. Cost: BinaryDeserializationContext<TInput> and IBinaryInputBase need a parallel ref-struct-friendly track (the existing pooled context cannot hold a ref struct). Major surgery on the deser core.
  2. MemoryMarshal.CreateReadOnlySpanFromNullTerminated-style hack — accept ReadOnlySpan<byte>, use Unsafe.AsRef/MemoryMarshal.GetReference to obtain a ref byte, then copy into a pooled byte[] before deserialization. Not zero-copy, defeats the purpose. Reject.
  3. Pinned-buffer trampoline — accept ReadOnlySpan<byte>, allocate a Memory<byte> view via a MemoryManager<byte>-like wrapper, delegate to ReadOnlyMemory<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 against byte[]-equivalent input.
  • Zero-alloc path verified for stackalloc-source spans (BenchmarkDotNet allocation diagnoser).
  • IBinaryInputBase (or successor interface) refactor preserves backward compatibility for existing ArrayBinaryInput / SequenceBinaryInput / AsyncPipeReaderInputAdapter consumers.
  • 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 BufferWriterBinaryOutputPipeWriter, 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 zeroArrayPool + 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.TryGetArray zero-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 the StreamPipeWriter._tailMemory reset race (ACCORE-BIN-I-...). N async cycles vs 1 in Bytes mode.
  • 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: Bytes OOMs; AsyncSegment works.
  • Kestrel transport / parallel-capable Pipe: pipeline parallelism makes AsyncSegment faster than Bytes for 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 large byte[] copy decisively beats N managed chunks.

Marketing claim — three-way honest comparison

"AcBinary offers a real choice. Bytes mode for typical throughput-priority workloads (matches MemoryPack's pseudo-streaming, often faster on the wire). AsyncSegment mode 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 } in AyCode.Core/Serializers/Binaries/. Default Bytes.
  • New mode field on AcBinarySerializerOptions: AcBinaryOutputMode OutputMode { get; set; } = AcBinaryOutputMode.Bytes;. (Note: subject to ACCORE-BIN-I-L8N5 thread-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:
      • Bytesvar bytes = Serialize(value, options); await stream.WriteAsync(bytes, ct); ArrayPool.Return(bytes);
      • Segmentvar pw = PipeWriter.Create(stream, new(leaveOpen: leaveOpen)); Serialize(value, pw, options); await pw.CompleteAsync();
      • AsyncSegmentvar pw = PipeWriter.Create(stream, new(leaveOpen: leaveOpen)); SerializeChunked(value, pw, options); await pw.CompleteAsync();
  • public static ValueTask SerializeAsync(object? value, Type type, Stream stream, ...) — non-generic, same dispatch (coordinated with ACCORE-BIN-T-N9G6).
  • leaveOpen parameter standard for stream-async serializers (System.Text.Json, MessagePack convention).
  • The Bytes mode uses a pooled byte[] from ArrayBinaryOutput to 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 against Deserialize<T>(byte[]) via MemoryStream in all three modes.
  • Cancellation propagates correctly (OperationCanceledException on 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 in Test_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 FileStream in AsyncSegment mode → peak managed-heap delta ≤ 1 MB throughout. Same payload in Bytes mode → peak ~100 MB (expected, documented).
  • API doc-string contains a "When to use which mode?" decision matrix; explicitly compares with MemoryPack's pseudo-streaming.
  • leaveOpen parameter 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:

  • Bytes mode: simplest, single contiguous byte[] (pooled) → Deserialize<T>(byte[]). Comparable to MemoryPack's DeserializeAsync (which does similar full-buffer-then-deser). Best for typical workloads.
  • Segment mode: zero-copy from PipeReader's natural ReadOnlySequence<byte> — no extra byte[] allocation. Best for mid-size payloads where allocation matters but pipeline overlap doesn't.
  • AsyncSegment mode: producer-drain Task and consumer-deser Task in parallel via AsyncPipeReaderInput. 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 against SerializeAsync(Stream, T) (T8K3) via MemoryStream in all three modes.
  • Cancellation propagates correctly (OperationCanceledException on 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 in Test_Benchmark_Results/Benchmark/DeserializeAsync_Stream_Modes.LLM.
  • Memory-bounded benchmark: 100 MB payload from FileStream in AsyncSegment mode → peak managed-heap delta ≤ 1 MB throughout. Same payload in Bytes mode → 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.
  • leaveOpen parameter 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):

  1. Promote BufferWriterBinaryOutput to 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.
  2. New ref struct AcBinaryWriter wrapper around BufferWriterBinaryOutput (or a dedicated impl) — explicit "this is the low-level writer" signal. More API surface but clearer mental model. Aesthetic alignment with MemoryPack.
  3. Skip entirely — the IBufferWriter<byte> overload is already lower-level than most consumers need; custom formatters can write to an ArrayBufferWriter<byte> and use IBufferWriter-style primitives. This is what BufferWriterBinaryOutput already 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 as BufferWriterBinaryOutput standalone-mode.
  • At least one example custom formatter ships in tests (e.g., a Vector3 struct 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-incompatibleType.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 = falsedisabled. 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 per AcBinarySerializerOptions.PolymorphismMode (Runtime/AQN today). This preserves the JSON-style flexibility for unattributed bases.
  • EnablePolymorphismFeature = true + [AcBinaryUnion(...)] declared → union-switch dispatch. SGen emits a closed switch (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 64134 / 192255 ranges.

Implementation outline:

  • AcBinarySerializableAttribute — new ctor parameter enablePolymorphismFeature, all existing ctors default it to true (backward compatible).
  • AcBinaryUnionAttribute — new attribute, AttributeUsage(AttributeTargets.Class | Interface, AllowMultiple = true).
  • Source generator — emit WriteUnion<TBase>(value, ctx, depth) and ReadUnion<TBase>(ctx, depth) static methods on the union-base type's generated writer/reader. Skipped entirely when EnablePolymorphismFeature = false.
  • Wire-format new marker + [byte tag][inner Object] body.
  • Runtime path: WriteValueNonPrimitive checks the wrapper's PolymorphismFeatureEnabled flag; when false, skips the value.GetType() != declaredType polymorphism branch entirely.

Acceptance:

  • EnablePolymorphismFeature = false: SGen-emitted dispatch contains zero is-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.md cross-references the attribute pattern; BINARY_OPTIONS.md documents PolymorphismMode. AcBinarySerializableAttribute doc-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):

  1. 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.
  2. Immutable record refactorset;init; on all configuration properties. Mutation requires with-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 = false after construction. Major version bump.
    • Con: source-generator coordination needed if SGen emits options-builder code that mutates properties.
  3. Read-only flag pattern (à la JsonSerializerOptions.MakeReadOnly()) — mutable by default, holder calls MakeReadOnly() on entry; subsequent property setters throw InvalidOperationException.

    • Pro: BCL-precedent — Microsoft adopted it for JsonSerializerOptions in .NET 7 (dotnet/runtime#74431) for exactly this problem. Familiar pattern for consumers.
    • Pro: minimal API surface change (one new method + IsReadOnly flag 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.

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

  1. AcBinarySerializerOptions.IsReadOnly { get; } — public bool property.
  2. AcBinarySerializerOptions.MakeReadOnly() — sets the flag; idempotent (no-op if already set).
  3. 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.");.
  4. AcBinarySerializer.Serialize<T> entry (and all sibling entries — Deserialize<T>, SerializeChunked, etc.): options.MakeReadOnly() before any property read.
  5. AcBinaryHubProtocol ctor: complete the BufferWriterChunkSize mutation before calling options.MakeReadOnly(). After ctor returns, the options instance is frozen for that protocol's lifetime.
  6. Doc-string update on AcBinarySerializerOptions class header: explicit "thread-safety contract" section explaining the freeze-on-first-use semantics.

Acceptance:

  • Concurrent stress test (16 threads × 1000 iterations) on a shared AcBinarySerializerOptions instance with property-mutation-attempts mid-iteration — all mutations after MakeReadOnly() throw InvalidOperationException; no silent corruption observed.
  • Existing tests pass unchanged (the MakeReadOnly is opt-in for the serializer entries; tests that build options + use them once continue to work transparently).
  • BINARY_ISSUES.md#accore-bin-i-l8n5 Status updated to Closed (YYYY-MM-DD) with a ### Resolution sub-section pointing to this TODO + the implementing commit.
  • Doc-string on AcBinarySerializerOptions documents the freeze-on-first-use contract; BINARY_FEATURES.md or BINARY_OPTIONS.md cross-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.cs files regenerate with FQN-based s_typeNameHash values.
  • Existing tests pass (auto-regen propagates; no manual hash literals in tests).
  • Wire format identical for WireMode=Compact (no metadata embedded).
  • UseMetadata=true paths 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 = 0x12345 instead 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 Metadata mode).
  • 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_typeNameHash unchanged.

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

  1. Switch to xxHash3 32-bit (incremental improvement) — but doubles the change scope (touch property hashes too if uniformity desired).
  2. Switch to xxHash3 64-bit (8 wire bytes instead of 4) — meaningful collision resistance, modest wire cost.
  3. 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] on JsonSerializer.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=false is set or an unattributed type is deserialized
  • SGen path is warning-free
  • Library compiles 0 warnings (suppressions added at the propagation barrier)
  • BINARY_FEATURES.md NativeAOT 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 files
  • AyCode.Core.Aot.csproj<Compile Remove="**/*.Runtime.cs" />; sets <PackageId>AyCode.Core.Aot</PackageId>; same version as full

Trade-offs:

  • No #if directives 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; .Aot variant 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-K9E4 first ([RequiresDynamicCode] attributes) — if that pattern handles the consumer-side scenarios well, .Aot may not be needed
  • The current Runtime fallback code is already well-isolated (mostly in AcSerializerCommon factories + AcBinaryDeserializer wrapper-based methods), so the file-split refactor is mechanically straightforward
  • Marketing decision: is binary size a central pillar? If yes, .Aot is a NuGet differentiator; if not, K9E4 alone is enough

Acceptance:

  • AyCode.Core.Aot.csproj produces a NuGet ~25-60 KB smaller than AyCode.Core after compression
  • .Aot build emits zero IL/AOT trim warnings (no suppressions needed because the Runtime path code is physically removed)
  • Round-trip tests pass on .Aot for all SGen types
  • .Aot throws a clear InvalidOperationException (not MissingMethodException) when a non-[AcBinarySerializable] type is encountered at deser time
  • BINARY_FEATURES.md NativeAOT 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:

  1. 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)
  2. Waiting for Avx10v1 / Avx10v2 to expose AVX-512BW-class primitives in 256-bit form (Intel's unified vector ISA — Avx10v1 already in .NET 9, Avx10v2 arrives 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 — DecodeUtf8SinglePass Vector512 (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 two Vector256<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 — DecodeUtf8SinglePass Vector256 (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 — DecodeUtf8SinglePass Vector128 (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ímke pattern) — 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 / Decode3ByteRun scalar-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 DecodeUtf8SinglePass entry, 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:
    1. Block load (Vector512 / Vector256 / Vector128)
    2. Classify each byte's UTF-8 sequence position via mask compares (start vs continuation, 1/2/3/4-byte sequence width)
    3. Compute output char count via popcount on start-byte mask + extra-char mask for 4-byte sequences
    4. Byte-compression for leader/continuation extraction (mask-driven PermuteVar / Shuffle)
    5. Combine leader + continuations into codepoints (shift + OR)
    6. Widen codepoints to UTF-16 chars (handle surrogate pairs for 4-byte sequences)
    7. 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.md documents 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 test covers correctness; per-tier benchmarks measure the multi-byte speedup
  • Phase 1+2 (AVX-512BW + Vector128 in CountUtf8Chars + EncodeUtf8SinglePass Phase 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 object properties: marker needed for type discrimination. Sentinel encoding only applies when the property type is statically string or string?.
  • Interning incompatible: sentinel cannot express StringInternFirst / StringInterned markers (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)

  1. Writer: branch in WriteString on property metadata flags (IsString, IsNotInterned, IsNotPolymorphic). If sentinel-eligible, emit VarUInt sentinelLength + content. Else fall through to existing marker-based encoding.
  2. Reader: matching branch in property reader. If sentinel-eligible (per property metadata), read VarUInt sentinelLength, dispatch on 0/1/N+1.
  3. SGen: emit sentinel-encoding variant for non-polymorphic non-interned string typed properties; emit existing marker-encoding for the rest.
  4. 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 = 0x10 or 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 false preserves 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 = Fast AcBinary 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 existing PropertySkip / default-omission caveat from ACCORE-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 WriteStringWithDispatch short-lane sokszor hívódik 10× repeated string-en

Suspect mechanisms (ranked by likelihood):

  1. AOT inline budget. NativeAOT is more conservative than the Tier 1 JIT in respecting AggressiveInlining for 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 (WriteStringWithDispatch short-lane, ReadString decode callback). If the AOT compiler emits call <method> instead of inlining, every iteration of the Repeated 10-string loop pays the call overhead.

  2. [Intrinsic] IsSupported constant folding. Avx512BW.IsSupported, Vector512.IsHardwareAccelerated, Vector256.IsHardwareAccelerated, Vector128.IsHardwareAccelerated should constant-fold per host on AOT. Verify via disasm — if any remain runtime checks, every iteration pays the branch cost (3 nested if-s in each Utf8Transcoder method).

  3. Vector256.LessThan<ushort> unsigned compare emulation. No native pcmpltw_unsigned on AVX2; JIT/AOT lowers to pminuw + 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.

  4. Method size cascade. The Utf8Transcoder method bodies grew with the V4N3 GetUtf8ByteCount addition. 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):

  1. NativeAOT publish dump:
    dotnet publish AyCode.Core.Serializers.Console -c Release -r win-x64 -p:PublishAot=true
    dumpbin /disasm <output.exe> > disasm.txt
    
  2. Locate EncodeUtf8SinglePass, DecodeUtf8SinglePass, GetUtf8ByteCount, CountUtf8Chars symbols in the disasm
  3. Verify constant folding on IsSupported checks — no run-time CMP/JMP at the path-selector branches; the dead branches eliminated
  4. Verify inlining at WriteStringWithDispatch / ReadString callers — if call <Utf8Transcoder.*> instructions remain, inlining failed
  5. Method size inspection — large method bodies hint at inline-eligibility issues; large prologue/epilogue at hot call sites is a tell
  6. Cross-compare with Tier 1 JIT disasm (run with DOTNET_TieredCompilation=0 + DOTNET_TC_QuickJit=0 to force Tier 1, dump the JIT-tier disasm via WinDbg or BenchmarkDotNet's [DisassemblyDiagnoser]) to confirm the gap is AOT-specific rather than algorithmic

Possible fixes (Open until disasm confirms which apply):

  • A. Method splitEncodeUtf8SinglePass → small dispatcher + per-tier inner methods (each Vector512 / Vector256 / Vector128 / scalar in its own AOT-inline-friendly small method). Same for DecodeUtf8SinglePass. 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> in csproj to constant-fold the IsSupported checks at AOT compile time. Alternative: separate per-ISA AOT publish artifacts.
  • D. Manual hot-path inlining — for the Repeated cell, hand-inline EncodeUtf8SinglePass short-string lane into WriteStringWithDispatch FixStr 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 Utf8TranscoderTests and 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}.IsHardwareAccelerated constant-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 a TypeReaderTable lambda-class static init-be — 0 method-call overhead a tier-on.
  • ⚠️ WriteStringWithDispatch NEM 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.
  • ⚠️ ReadStringUtf8WithCharLen NEM inline-olódott — saját body, sok call-site.
  • string.Create callback __DelegateCtor — disasm szerint test static; jne skip ctor minta = 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):

  • WriteStringWithDispatch dispatcher: 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:

  1. 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.
  2. 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.
  3. A __DelegateCtor cache-elt — string.Create callback nem hot-path overhead-forrás.
  4. Strukturális struktúra megőrizve: WriteStringDispatchLong és WriteStringFastWire kü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 (O7G2ThrowStringTooLong) — 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 ≤ 15FixArrayBase + itemCount marker (1 byte total header)
  • itemCount > 15 → existing Array marker + VarUInt count (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:

  1. Allocate FixArrayBase (16 contiguous values from the H2Q6-freed range)
  2. Add IsFixArray(byte marker), DecodeFixArrayCount(byte marker), EncodeFixArray(int count) helpers in BinaryTypeCode.cs
  3. Writer: branch in WriteArray and equivalent ScanPass list-writers, emit FixArray for count ≤ 15
  4. Reader: extend marker dispatch in ReadValue / SkipValue / ReadArray
  5. SGen: regenerate readers/writers with IsFixArray dispatch in the array-typed property paths
  6. Round-trip tests for boundary itemCount values: 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 — VarUInt short-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.LLM baseline'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) with IsFixArray, DecodeFixArrayCount, EncodeFixArray helpers
  • Writer + reader dispatch with boundary tests (count = 0, 1, 14, 15, 16, 17)
  • SGen-regenerated readers/writers correctly dispatch via IsFixArray for array-typed properties
  • Round-trip tests pass, no Ser/Deser regression vs current Array path
  • 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-H2Q6 lands (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 / S2X9 if their scope justifies)

Future extension (not part of this entry)

  • FixDict analog — same pattern for Dictionary marker (67) with kvCount 0-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 for S5L8/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 bytesWritten switch
  • The 3-way actualHeader header-write switch
  • The shift = reserveHeader - actualHeader compute (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

  • WriteStringWithDispatch Small fast path emits identical wire bytes as the generic path for charLength ≤ 63 (round-trip parity)
  • Benchmark on Repeated/Medium cells shows ≥ 1% Ser improvement vs. post-A-direction baseline (2026-05-07_09-39-09.LLM or later)
  • No regression on Large/Deep cells (long-string path untouched)
  • Round-trip tests pass on the boundary charLength = 63 and charLength = 64 cases

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 ≤ 63bytesWritten ≤ 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.GetString BCL call (virtual dispatch + EncoderFallback overhead) instead of string.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 _useStringCaching by 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

  • ReadStringUtf8Cached cache MISS path uses string.Create(charLength, ..., DecodeUtf8SinglePass) — no BCL Utf8NoBom.GetString on 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 of WriteStringWithDispatch runs on every string write — runtime check on a path-dominant (Compact) call site
  • Reader: if (context.FastWire) inside ReadStringSmall runs 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 / ReadStringSmall method 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) → ReadStringSmall becomes Compact-only (no if (FastWire) branch, smaller method body)
  • FastWire uses new StringFastWire → dedicated ReadStringFastWire reader, FastWire-only logic
  • Writer's FastWire branch emits StringFastWire instead of StringSmall

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

  1. BinaryTypeCode.StringFastWire = 131 constant + helper updates (IsString range check + dispatch)
  2. WriteStringWithDispatch FastWire branch emits StringFastWire (was StringSmall)
  3. New ReadStringFastWire<TInput> static reader — [VarUInt charCount][UTF-16 bytes] decode, no Compact-mode branching
  4. ReadStringSmall<TInput> simplified — Compact-only, drops if (context.FastWire) branch
  5. TypeReaderTable[StringFastWire] registration
  6. SkipValue case StringFastWire: — same skip layout as StringSmall FastWire branch (charCount VarUInt + 2 × charCount bytes)
  7. SGen template EmitReadString — new case StringFastWire: block (FastWire-only branch); case StringSmall: simplified to Compact-only
  8. 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.cs reservation 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 manual dumpbin workflow 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) and RuntimeMoniker.NativeAot90 simultaneously — 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

  1. New project: AyCode.Core.Serializers.Benchmark (or .Bdn) — separate csproj for clean BDN dependency isolation. AOT-publishable for the AOT job.
  2. TestDataSet bridge: reuse the existing TestDataFactory / TestDataSet types from AyCode.Core.Tests.TestModels so the data-shape is identical to the custom bench.
  3. 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);
    }
    
  4. Multi-cell coverage: separate benchmark classes per workload-shape (StringSerializationBenchmark, ObjectGraphBenchmark, NestedDeepBenchmark) — clean grouping in BDN output.
  5. NativeAOT-job config: <PublishAot>true</PublishAot> conditionally (mirroring Console project pattern); BDN's NativeAOT job auto-publishes the bench-runner.
  6. 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.Benchmark project compiles + runs cleanly on both JIT (net9.0) and NativeAOT
  • Reuses existing TestDataFactory / TestDataSet types — 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.md references 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% — final byte[]-alloc + content-memcpy (= a 39.77% _Memmove Self nagy része)

MemPack-összehasonlítás (referenciaként)

A MemPack Serialize<T>(T value) mechanizmus:

  1. [ThreadStatic] writer-state — nincs pool-bérlés, nincs lock, nincs concurrent dictionary lookup
  2. ReusableLinkedArrayBufferWriter — linked chunk-list (4 KB → 8 KB → 16 KB geometriai); buffer-grow = új chunk hozzáadása, nincs memcpy a régi adaton
  3. ToArrayAndReset() — 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:

  1. 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ó)
  2. Csoportonként worst-case-size compute compile-time-on (a primitív type-ok mérete fix vagy worst-case ismert)
  3. Egyetlen EnsureCapacity(sum) + bulk *Unsafe write-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 state allokáció egyszer fut

Refactor szempontok:

  • A BinarySerializationContext state-tárolása nem thread-safe önmagában — pool-bérlés vagy thread-static mind a single-thread haszálatot biztosítja
  • Az options paramé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:

  1. A WriteProperties method elején egyetlen HasPropertyFilter check
  2. 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, nincs goto skip_X
    • Slow path (filter aware — separate static method): a jelenlegi viselkedés

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 WriteProperties Self 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 S2X9 markerless lane sprint kontextusában mérni

Sorrend

  1. A + F kombinálva — SGen WriteProperties template á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 csak AcBinarySourceGenerator.cs-en, wire-format változatlan.
  2. B — ~1-2pp javulás, ugyanaz a pattern mint a K7M3 FastWire batch-write
  3. C — ~2-4pp, de NAGY refactor (thread-safety, pool semantics felülvizsgálat)
  4. D — alacsony prioritás (kis hatás, nagy refactor)
  5. E — csak S2X9 kontextusban

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-occurrence
  • ObjectWithTypeName — polimorf (runtimeType != declaredType)
  • ObjectFullMarkerIId / ObjectFullMarkerAllRefHandling=IId|All first-occurrence
  • ObjectRef / 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 (ObjectObjectVarUInt, StringStringUtf16). 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ásd WriteVarInt 514. 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)
  • IntEncoding enum (Default = ZigZag VarInt, VarUInt = NoZigZag)
  • StringEncoding enum (Default = UTF-8, Utf16 = UTF-16)
  • AcBinaryOptions.IntEncoding és AcBinaryOptions.StringEncoding runtime fallback opciók

Encoding-választás precedenciája (writer-side)

  1. Property attribute (legerősebb) — pl. [AcBinaryEncoding(StringEncoding.Utf16)]
  2. Class attribute — pl. [AcBinarySerializable(IntEncoding=VarUInt)]
  3. AcBinaryOptions runtime opció — pl. options.StringEncoding = Utf16
  4. 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. Az Order osztá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 → ObjectVarUInt markert 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 Order typebe-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

  1. BinaryTypeCode const-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és BINARY_FORMAT.md-ben.
  2. AcBinaryEncodingAttribute + IntEncoding + StringEncoding enum-ok — új fájlok az AyCode.Core/Serializers/Binaries/ mappában.
  3. AcBinaryOptions.IntEncoding + AcBinaryOptions.StringEncoding opciók hozzáadása (default = Default).
  4. WriteStringUtf16 / ReadStringUtf16 context-helper-ekMemoryMarshal.Cast<char,byte> direct copy + length-prefix (VarUInt char-count).
  5. Runtime writer reflectionBinarySerializeTypeMetadata cache: IntEncoding, StringEncoding-per-property flag-ek (attribute-alapján). Encoding-emit a precedencia szerint.
  6. SGen writer template — attribute-feldolgozás EmitWriteValue-ban: ha attribute → compile-time hard-coded emit; ha nincs → runtime-branch emit a context option-en.
  7. SGen reader templateEmitReadValue marker-dispatch-szel (object-marker scope-encoding-mode tracking + string-marker per-property dispatch).
  8. Runtime reader update — object-marker dispatch a scope-encoding-state-be (pl. BinaryDeserializationContext.CurrentIntEncoding), string-marker per-property dispatch.
  9. 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).
  10. Doc: BINARY_FORMAT.md wire-format spec, BINARY_OPTIONS.md új opciók, BINARY_SGEN.md precedencia + 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.md szinkronban a wire- és attribute-világgal
  • A meglévő WireMode=Fast/Compact distinction-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) vagy ObjectNoZZ (rövidebb)?
  • [AcBinarySerializable]-on belül vegyük fel a IntEncoding paramétert, vagy külön [AcBinaryEncoding] attribute legyen object-szinten is (és a [AcBinarySerializable] változatlan)?
  • AcBinaryOptions.WireMode jövője: a régi Fast/Compact enum migrálódjon az új IntEncoding/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 ReadStringSmallReadStringSmallCompact / ReadStringSmallFastWire split, 3 call sites still inline FastWire ? FW : Compact ternary:

  • AcBinaryDeserializer.csReaders[BinaryTypeCode.StringSmall] lambda (TypeReaderTable static init)
  • AcBinaryDeserializer.cs — cross-type populate PropertyAccessorType.String case (~line 1055)
  • AcBinarySourceGenerator.GenReader.csEmitReadString switch case StringSmall emit (~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 (Nullreturn 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.