8.1 KiB
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 —HasCachingcheck +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) WriteObjecthandles 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 |
|---|---|
| 0–63 | 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