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

8.1 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 (full type dispatch).

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);
}

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