12 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.
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);
}
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 atryblock inline.OnSerializing/OnDeserializedlifecycle 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 theusingin the entry frame (Serialize<T>), never in the generatedWriteProperties.
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, andMinWriteSizebaked in. Registered fromModuleInitinto aGeneratedMetadataRegistry;GetWrapperSlowconsults it before falling back to the reflectionMetadataFactory. A lazyTypeMetadataBase.RuntimeInit()builds theExpression.Compileproperty accessors only when needed (runtime-only type, orUseGeneratedCode=false). SGen types never touch their own runtime accessors →RuntimeInitskipped. 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/ScanForDuplicatesbecomes the residual first-call cost. Candidates to evaluate (measure first):[MethodImpl(MethodImplOptions.AggressiveOptimization)]on generated methods, backgroundRuntimeHelpers.PrepareMethodfromModuleInit, 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.