AyCode.Core/AyCode.Core/docs/BINARY/BINARY_SGEN.md

12 KiB
Raw Blame History

AcBinary Source Generation (SGen)

Source-generated serialization architecture, hybrid execution model, bridge methods, and code generation patterns. For modifying SGen writers or the runtime ↔ SGen boundary.

Wire format: BINARY_FORMAT.md | Options: BINARY_OPTIONS.md | Implementation: BINARY_IMPLEMENTATION.md | Writers: BINARY_WRITERS.md

Overview

Two execution modes, seamlessly interoperable in a single serialization run:

Mode Trigger Property access Type dispatch Performance
SGen [AcBinarySerializable] + UseGeneratedCode=true Unsafe.As<T> direct field Compile-time known Fastest
Runtime No attribute or UseGeneratedCode=false Compiled delegates (GetValue) Reflection + interface checks Flexible

Interfaces

IGeneratedBinaryWriter

void WriteProperties<TOutput>(object value, BinarySerializationContext<TOutput> context, int depth);
void ScanObject<TOutput>(object value, BinarySerializationContext<TOutput> context, int depth);
void ScanForDuplicates<TOutput>(object value, BinarySerializationContext<TOutput> context);
  • WriteProperties: writes all properties directly (no marker, no ref handling — caller handles)
  • ScanObject: recursive graph walk for duplicates (strings + IId objects)
  • ScanForDuplicates: entry point — HasCaching check + ScanObject + SortWritePlan

IGeneratedBinaryReader

object? ReadObject<TInput>(BinaryDeserializationContext<TInput> context, int depth, int cacheIndex);
void ReadProperties<TInput>(object value, BinaryDeserializationContext<TInput> context, int depth);

SGen Root Fast Path

Problem: Runtime dispatch chain for root object: ~12 calls, ~8 branches before first property byte. MemoryPack: ~2 calls.

Solution: AcBinarySerializer.Serialize<T> checks for GeneratedWriter before the full dispatch chain:

if (options.UseGeneratedCode)
    wrapper = GetWrapper(runtimeType)
    if (wrapper.GeneratedWriter != null)
        ScanForDuplicates → WriteHeader → WriteObject(wrapper) → return
// else: full path (IQueryable/Expression + WriteValue dispatch)

Skipped steps: is IQueryable check, IsExpressionType, TryWritePrimitive (Type.GetTypeCode + 15-case switch), WriteValueNonPrimitive (4 interface checks + GetWrapper), 2 method call levels.

Safety guarantees:

  • GeneratedWriter exists → type is NOT IQueryable, Expression, primitive, byte[], IDictionary, IEnumerable
  • SGen only generates for [AcBinarySerializable] object model types
  • Root always uses value.GetType() → no declared vs runtime type mismatch (polymorphism safe)
  • WriteObject handles FixObj slots, UseMetadata, RefHandling → wire format identical
  • Hybrid children use bridge methods → unchanged behavior

Applies to: both Serialize<T>(T, options) (byte[]) and Serialize<T>(T, IBufferWriter, options).

Hybrid Execution Model

SGen root type with non-SGen child types works transparently. SGen-generated WriteProperties calls bridge methods for unknown child types:

SGen Root (WriteProperties)
  ├─ SGen Child A → direct SGen→SGen call (WriteProperties)
  ├─ Runtime Child B → WriteValueGenerated (bridge → WriteValueNonPrimitive)
  ├─ String property → WriteStringGenerated (bridge → WriteString)
  └─ SGen Child C with runtime grandchild
       ├─ SGen grandchild → direct call
       └─ Runtime grandchild → WriteObjectGenerated (bridge → GetWrapper + WriteObject)

Key: SGen types can call directly into other SGen types' WriteProperties (zero dispatch). Non-SGen children fall back to runtime via bridge methods.

Bridge Methods

Located in AcBinarySerializer.cs region "Generated Writer Bridge Methods". Called by SGen-generated code to transition to runtime pipeline.

Bridge Called when What it does
WriteValueGenerated(value, type, ctx, depth) SGen encounters non-SGen complex child WriteValueNonPrimitive (byte[]? IDictionary? IEnumerable? GetWrapper → WriteObject)
WriteObjectGenerated(value, type, ctx, depth) SGen knows child is object (not collection) GetWrapper(type)WriteObject
WriteObjectGenerated(value, type, slot, ctx, depth) SGen knows child is SGen object with known slot GetWrapper(type, slot)WriteObject. Slot = compile-time known wrapper index, avoids dictionary lookup
WriteStringGenerated(value, ctx) SGen writes string property null → PropertySkip, empty → StringEmpty, else → WriteString (with interning)
ScanValueGenerated(value, type, ctx, depth) SGen scan encounters non-SGen child → runtime ScanValue for reference/string tracking

Wrapper Slot System

Each SGen type gets a unique slot index via AllocateWrapperSlot() (Interlocked.Increment).

Slot range Purpose
063 FixObj markers — runtime polymorphic types (dynamic assignment)
64+ SGen types (compile-time stable, sequential allocation)

GetWrapper(type, slot): first call per slot per context → populates from GetWrapper(type). Subsequent calls → direct array index _wrapperSlots[slot]. O(1) lookup, no dictionary.

Generated Code Patterns

WriteProperties (generated)

// Generated for [AcBinarySerializable] type
void WriteProperties<TOutput>(object value, BinarySerializationContext<TOutput> ctx, int depth)
{
    var obj = Unsafe.As<MyType>(value);       // No cast, no box
    ctx.WriteInt32Property(obj.Id);            // Direct typed write
    AcBinarySerializer.WriteStringGenerated(obj.Name, ctx);  // String bridge
    // SGen child → direct call:
    ChildWriter.WriteProperties(obj.Child, ctx, depth + 1);
    // Non-SGen child → bridge:
    AcBinarySerializer.WriteValueGenerated(obj.Other, typeof(OtherType), ctx, depth + 1);
}

### ScanObject (generated)

```csharp
void ScanObject<TOutput>(object value, BinarySerializationContext<TOutput> ctx, int depth)
{
    var obj = Unsafe.As<MyType>(value);
    // Track this object for ref handling
    if (!ctx.TrackObjectForScan(value)) return;  // Already visited
    // Scan string properties for interning
    ctx.ScanStringProperty(obj.Name);
    // SGen child → direct scan:
    ChildWriter.ScanObject(obj.Child, ctx, depth + 1);
    // Non-SGen child → bridge:
    AcBinarySerializer.ScanValueGenerated(obj.Other, typeof(OtherType), ctx, depth + 1);
}

SGen Output Constraints

Design rules for anyone modifying AcBinarySourceGenerator.cs. Violating these silently regresses the SGen hot path.

No EH regions in generated hot methods. Generated WriteProperties / ScanObject / ScanForDuplicates / ReadObject / ReadProperties MUST NOT emit try, catch, finally, or using blocks. On .NET 9 (project minimum), the CoreCLR JIT refuses to inline any method with an EH region — AggressiveInlining is silently ignored, and the SGen Root Fast Path collapses to a regular call frame per property write. .NET 10 partially lifts this for same-module try-finally (dotnet/runtime#112998, merged 2025-03-20); not yet our minimum runtime, and catch / cross-module / P/Invoke-stub cases remain blocked even on .NET 10+. Hard rule for SGen output regardless of runtime — generated method must work on .NET 9 AND keep inline-friendliness on .NET 10+.

Future-feature trap. When adding generator features that seem to need EH:

  • [CustomSerializer] / [CustomDeserializer] attribute hooks → wrap user code in a cold helper called from the generated method, not in a try block inline.
  • OnSerializing / OnDeserialized lifecycle callbacks → cold helper.
  • Validation attributes ([Validate], [Required]) → cold helper that throws; the generated method calls it without try/catch.
  • Rented-buffer cleanup (using var pooled = ...) → keep the using in the entry frame (Serialize<T>), never in the generated WriteProperties.

Straight-line rule. Generated hot methods: Unsafe.As<T> cast → null/depth check → property write or bridge call → repeat. No exception handling, no resource ownership, no early-return cleanup. Resource ownership lives at Serialize<T> / Deserialize<T> entry, not per-property.

See BINARY_IMPLEMENTATION.md Rule #3 (Inlining barriers) for JIT-level rationale; BINARY_TODO.md#accore-bin-t-t5j8 for the audit task enforcing this on existing output.

Object Marker Bridge — Metadata Caching

WriteObjectFullMarkerIId / WriteObjectFullMarkerAll in PropertyWriters.cs: when UseMetadata=true, GetWrapper result and wrapper.Metadata are cached in a local variable at the method entry. This avoids redundant GetWrapper + value.GetType() calls in the ref-handling and non-ref branches.

Performance Characteristics

Aspect SGen Runtime
Property access Unsafe.As<T> (0 overhead) Compiled delegate invoke
Type dispatch Compile-time resolved Interface checks + GetTypeCode switch
Wrapper lookup Slot array index O(1) Dictionary lookup (amortized O(1) but hashing)
String write Bridge to same WriteString Same WriteString
Ref handling Same IdentityMap Same IdentityMap
Wire format Identical Identical
Root dispatch Fast path (3 checks) Full chain (~12 calls, ~8 branches)

Configuration

Option Default Effect on SGen
UseGeneratedCode true false → ignores all GeneratedWriter/Reader, uses runtime only
UseMetadata false false + SGen → IsDirectObjectWrite=true → SGen inlines property writes
ReferenceHandling All None + StringInterning=None → no scan pass (single-phase)

When to Use SGen

  • Hot-path types: types serialized frequently (SignalR messages, cache entries)
  • Large object graphs: deep nesting benefits most from zero-dispatch property access
  • Small payloads: root fast path eliminates dispatch overhead that dominates at small sizes
  • Not needed for: one-off serialization, 3rd-party types, Expression/IQueryable types

Cold-Start Overhead & Planned Mitigation

First use of an SGen type per process incurs reflection + Expression.Compile in the BinarySerializeTypeMetadata / BinaryDeserializeTypeMetadata constructors — dominant first-call cost today. Subsequent calls reuse cached metadata and wrappers.

See BINARY_ISSUES.md#accore-bin-i-n6q3 for the full cost chain and BINARY_TODO.md#accore-bin-t-w9f1 / #todo-04 for planned work:

  • ACCORE-BIN-T-W9F1 — Compile-time metadata generation. Source generator emits pre-built BinarySerializeTypeMetadata (and deserialize counterpart) with hashes, flags, and MinWriteSize baked in. Registered from ModuleInit into a GeneratedMetadataRegistry; GetWrapperSlow consults it before falling back to the reflection MetadataFactory. A lazy TypeMetadataBase.RuntimeInit() builds the Expression.Compile property accessors only when needed (runtime-only type, or UseGeneratedCode=false). SGen types never touch their own runtime accessors → RuntimeInit skipped. Hybrid mode is unaffected: non-SGen child types continue to run the reflection factory.
  • ACCORE-BIN-T-T5J8 — JIT Tier 1 warmup. After ACCORE-BIN-T-W9F1, JIT of generated WriteProperties / ScanObject / ScanForDuplicates becomes the residual first-call cost. Candidates to evaluate (measure first): [MethodImpl(MethodImplOptions.AggressiveOptimization)] on generated methods, background RuntimeHelpers.PrepareMethod from ModuleInit, ReadyToRun (R2R) in consuming-project publish config, code chunking for oversized methods.

Wire format is unchanged by both TODOs — this is dispatch / startup optimization only.