# AcBinary SGen — Per-Property Emit Optimization Notes

Working notes for post-baseline SGen-emit micro-optimization. Brainstorming output (2026-05-15).

> **Not a TODO entry.** Open TODO ID: `BINARY_TODO.md#accore-bin-t-k9m3` covers wire-codec hoist + feature-conditional emit (Phase A/B/C). This doc is the **per-property emit-condensation companion** — narrower scope, mostly emit-text shape changes (no new runtime API).

## Status

- **BDN baseline:** in progress (separate session)
- **Implementation:** blocked on baseline
- **This doc:** brainstorming + methodology snapshot

## Motivation

Inspection of `TestOrderItem_All_True_GeneratedReader.g.cs` (247 lines, 9 properties) vs MemPack equivalent (~9 lines of per-property emit) shows the AcBinary SGen-emit body is ~22× larger per property. Most of the gap is **legitimate** (AcBinary supports ref-tracking, interning, polymorph dispatch, FixObj slots — MemPack does not), but a measurable fraction is **mechanical duplication** of the same 30+ line dispatch pattern across siblings of the same kind (Complex, Collection-element, String).

| Property kind | AcBinary lines/prop | MemPack lines/prop |
|---|---:|---:|
| Complex (Tag, Assignee, ItemMetadata) | ~37 | 1 |
| Collection (Pallets) | ~45 | 1 |
| String (ProductName) | ~30 | 1 |
| Primitive (Id, Quantity, Status, UnitPrice) | 1-3 | 1 (batched) |

Cumulative reduction if Ötlet A + F.4 + G land: **~247 → ~90 lines** on this representative DTO (~36%).

## Methodology — measurement order

| Step | Tool | Purpose |
|---|---|---|
| 1 | `DOTNET_JitDisasm=*GeneratedReader*ReadProperties*` ad-hoc on `Console.FullBenchmark` | Sanity-check: does the JIT already merge the switch to cmp-chain? If yes, Ötlet F.4 nyereség = source-only (no perf win). |
| 2 | BDN baseline (separate session, in progress) | Cell-level wall-clock truth. ~1-3% noise floor. |
| 3 | Implement Ötlet A in isolation | Single-method MemPack-validated pattern, measurable independently on string-rich cell (Repeated Strings). |
| 4 | If A wins → BDN re-run → implement F.4 | Switch→if-else chain branch-predictor swap MUST be BDN-verified before extending. |
| 5 | If F.4 wins → implement G | Mechanical follow-up (same pattern on collection element). |
| 6 | Disasm-baseline (dedicated minimal project, **only if** BDN deltas are noise-floor-near 1-3%) | `AyCode.Core.Benchmarks.Disasm` proj, `TestOrderItem_All_True` chain, JIT Tier-1 + AOT. ENV: `DOTNET_TC_QuickJit=0`. Output `.disasm-baseline/{jit|aot}/{before|after}/`. |

**Anti-pattern to avoid:** asm-driven optimization (looking at asm first, "finding" something to fix). Disasm is **confirm-tool**, not explore-tool.

## Ideas — ranked

### Ötlet A — `TryReadStringProperty()` hoist ⭐⭐⭐

Single context method replacing the inline 30-line switch at every string-property emit site. Hot+cold split — FixStrAscii + Null/StringEmpty/StringSmall/StringInterned inline, rare markers (Medium, Big, Ascii long, InternFirst*) in a separate `[NoInlining]` cold-path method.

```csharp
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal bool TryReadStringProperty(out string? value)
{
    if (FastWire) { value = ReadStringUtf16Markerless(); return true; }
    var tc = ReadByte();
    if (tc == BinaryTypeCode.PropertySkip) { value = null; return false; }
    if (BinaryTypeCode.IsFixStrAscii(tc))
    {
        var len = BinaryTypeCode.DecodeFixStrAsciiLength(tc);
        value = len == 0 ? string.Empty : ReadAsciiBytesAsString(len);
        return true;
    }
    switch (tc)
    {
        case BinaryTypeCode.Null:           value = null;          return true;
        case BinaryTypeCode.StringEmpty:    value = string.Empty;  return true;
        case BinaryTypeCode.StringSmall:    value = ReadStringSmall(); return true;
        case BinaryTypeCode.StringInterned: value = GetInternedString((int)ReadVarUInt()); return true;
    }
    value = ReadStringPropertyColdPath(tc);
    return true;
}

[MethodImpl(MethodImplOptions.NoInlining)]
private string? ReadStringPropertyColdPath(byte tc) => tc switch
{
    BinaryTypeCode.StringMedium             => ReadStringMedium(),
    BinaryTypeCode.StringBig                => ReadStringBig(),
    BinaryTypeCode.StringAscii              => ReadPlainStringAscii(),
    BinaryTypeCode.StringInternFirstSmall   => ReadAndRegisterInternedStringSmall(),
    BinaryTypeCode.StringInternFirstMedium  => ReadAndRegisterInternedStringMedium(),
    _ => null
};
```

**Caller emit:** 2 lines per string property. `if (TryReadStringProperty(out var v)) obj.Name = v;`.

**Risk:** caller-side `bool` branch (the `PropertySkip` signal). Trivial vs the 30 lines it replaces.

**Feature-flag interaction:** when `EnableInternStringFeature=false`, the `StringInterned` + cold-path `InternFirst*` cases are dead branches. The JIT cannot eliminate them per-type (the method is shared across all call sites). Option: **two methods** — `TryReadStringProperty` (full) + `TryReadStringPropertyNoIntern` (intern cases omitted), and SGen-emit picks based on the flag. Defer until A is validated.

**Expected gain:** 5-15% Deser on string-rich DTOs (per MemPack-pattern parallel). Verify with `Repeated Strings` cell.

### Ötlet F.4 — Complex emit condensed-inline ⭐⭐⭐

Pure SGen-emit text change. No new runtime API. 37 lines → 12 per Complex property by collapsing the 5-case switch to an if-else chain with hot path (Object/Null) front-loaded.

```csharp
// 12 lines, current ~37:
var tc_Assignee = context.ReadByte();
if      (tc_Assignee == BinaryTypeCode.PropertySkip) { /* skip */ }
else if (tc_Assignee == BinaryTypeCode.Null)         { /* leave null */ }
else if (tc_Assignee == BinaryTypeCode.ObjectRef)
    obj.Assignee = (SharedUser_All_True)context.GetInternedObject((int)context.ReadVarUInt())!;
else
{
    int ci = (tc_Assignee == BinaryTypeCode.ObjectRefFirst) ? (int)context.ReadVarUInt() : -1;
    if (tc_Assignee < BinaryTypeCode.Object) { context.GetWrapper(typeof(SharedUser_All_True), tc_Assignee); if (tc_Assignee >= context._nextRuntimeSlot) context._nextRuntimeSlot = tc_Assignee + 1; }
    var rc = new SharedUser_All_True();
    if (ci >= 0) context.RegisterInternedValueAt(ci, rc);
    SharedUser_All_True_GeneratedReader.Instance.ReadProperties(rc, context);
    obj.Assignee = rc;
}
```

**Branch-predictor risk:** the current switch on non-contiguous markers (0x40, 0x41, 0x46, 0x4C, 0x4D + FixObj-range) likely already lowers to cmp-chain (no jump-table) on x64 — disasm sanity-check (step 1) decides whether F.4 perf-impact is real or source-only. Either way, **direct call preserved** (no virtual dispatch).

### Ötlet G — Collection-element emit condensed ⭐⭐⭐

Same condensed pattern applied to the per-iteration switch inside the collection-element loop (~45 lines → ~15). Mechanical follow-up to F.4. Don't ship before F.4 is validated.

### Ötlet B — Multi-out unmanaged batch (MemPack `ReadUnmanaged<T1,T2,T3>`-style) ⭐⭐

MemPack emits `reader.ReadUnmanaged(out __TrayCount, out __Status, out __Weight)` — single `MemoryMarshal.Read<TypedTuple>`. AcBinary equivalent: limited applicability.

- Compact mode: markers between properties → batch impossible.
- FastMode markerless: viable, but only for fixed-size primitives (`Int32`, `Double`, `Guid`). VarInt/VarLong/Decimal not fixed-size → batch-incompatible.
- Caller-side `ref obj.X` blocked for property setters (only fields). `out` locals + assign-after pattern = no net win.

**Low priority.** Defer; revisit only after A/F.4/G land.

### Ötlet H — FastMode primitive batch ⭐⭐

In FastMode the wire is markerless byte-stream; consecutive unmanaged properties can be read as one `Unsafe.Read<TypedTuple>` via a synthesized struct. SGen detects consecutive unmanaged-property runs, emits batch-read. Noop on Compact mode.

**Scope:** FastMode-only deployments. Out of first batch.

### Ötlet F.1 / F.2 / F.3 — Per-type Complex helpers ⭐⭐

Three variants for moving the Complex dispatch into a per-type helper method:

| Variant | Mechanism | Blocker |
|---|---|---|
| F.1 | `static void TryReadTag(ctx, ref Tag target)` | `ref` to property setter not legal C#. Only works on fields. |
| F.2 | `static Tag? ReadTagProperty(ctx)` return-value + sentinel for `PropertySkip` | Sentinel allocation, `ReferenceEquals` overhead. |
| F.3 | Generic context method with struct-handler constraint (`where THandler : struct, IReader<T>`) | Bonyolult template emit; per-type struct-handler extra generated code; pays JIT generic-specialization cost. |

All preserve direct call (no virtual dispatch). All produce ~5 lines per Complex property at the emit site.

**Trade-off vs F.4:** F.1-3 yield smaller emit (~5 lines) but require new runtime surface OR new emit machinery. F.4 yields ~12 lines but pure emit-text change. **F.4 first** — lower risk, no API churn. Revisit F.2/F.3 only if F.4 hot-path verified-equivalent and the further line-count win is worth the surface cost.

### Ötlet C — Cold-path `[NoInlining]` audit ⭐

Mark `ReadStringMedium` / `ReadStringBig` / `ReadPlainStringAscii` with `[MethodImpl(NoInlining)]` if they don't already inline (body size). Marginal — verify with disasm only if BDN flags string-cell.

### Ötlet D — Extended FixStr marker range ⭐

`FixStrAscii` covers 1-31 byte ASCII (markers 135-166). Could be extended with `FixStrAsciiMedium` (32-63 byte) or `FixStrSmallNonAscii` (1-19 byte non-ASCII). **Wire-format breaking change** — separate project-level decision, not now.

### Ötlet E — Property-count object header (MemPack-style) ⭐

MemPack `TryReadObjectHeader(out count)` → fix-schema, no per-property `PropertySkip` marker. AcBinary chose property-marker pattern for schema-evolution flexibility. **Design-philosophy conflict** — not a candidate.

## Overlap analysis — string-marker switch

| Marker pair | Wire-payload overlap | Mergeable? |
|---|---|---|
| `StringSmall` ↔ `StringInternFirstSmall` | Both `[charLen:8][utf8Len:8][bytes]` body | ❌ InternFirst has cacheIdx **before** payload + cache-register **after** — structurally different wire position. |
| `StringMedium` ↔ `StringInternFirstMedium` | Both `[charLen:16][utf8Len:16][bytes]` body | ❌ Same as above. |
| `StringInterned` ↔ `InternFirst*` | Both VarUInt cacheIdx first | ❌ Different behavior (lookup vs register+decode). |
| `FixStrAscii` ↔ `StringAscii` | Both ASCII payload | ✅ Already share `ReadAsciiBytesAsString(len)`. Properly factored. |

**Conclusion:** wire-format is definitionally fragmented; no structural merge opportunity beyond the existing `ReadAsciiBytesAsString` helper. The win is at the **emit-site hoist** (Ötlet A), not at the wire-codec level.

## Minor observation — enum cast

Current emit:
```csharp
{ var ev = context.ReadVarInt(); obj.Status = Unsafe.As<int, TestStatus>(ref ev); }
```

C# native cast `(TestStatus)context.ReadVarInt()` is bit-identical at the JIT level. Cleaner emit, marginal. Can be folded into a future emit-tidy pass.

## Cumulative impact estimate

`TestOrderItem_All_True_GeneratedReader.g.cs` (247 lines, 9 props) after A + F.4 + G:

| Component | Current | Projected | Δ |
|---|---:|---:|---:|
| String (1 prop) | ~30 | 2 | -28 |
| Complex (4 props) | 4 × 37 = 148 | 4 × 12 = 48 | -100 |
| Collection (1 prop) | ~45 | ~15 | -30 |
| Primitives (4 props) | ~6 | ~6 | 0 |
| Boilerplate (header, enum, decimal) | ~18 | ~18 | 0 |
| **Total** | **~247** | **~89** | **-158 (~64%)** |

Smaller IL → faster cold-start JIT, smaller assembly, smaller i-cache footprint per hot type. Native AOT publish image shrinks proportionally.

## Implementation order — final

1. **Now (ad hoc):** `DOTNET_JitDisasm=*GeneratedReader*ReadProperties*` on `Console.FullBenchmark` — verify what the JIT actually emits for the current switch. F.4 prioritás függ ettől.
2. **BDN baseline** (separate session, in progress).
3. **Ötlet A** — isolated, measured on Repeated Strings cell.
4. **Ötlet F.4** — only if A wins AND disasm shows switch is cmp-chain (i.e. condensed if-else is wire-equivalent, not regressing branch prediction).
5. **Ötlet G** — only if F.4 wins.
6. **Ötlet H** — separate FastMode-only batch, not first-batch scope.

## Open questions

- Should `TryReadStringProperty` have a `NoIntern` twin for `EnableInternStringFeature=false` types? (Defer to post-A validation.)
- F.4 if-else order: hot path is `Object` (no ref-handling, fresh instance) — should be the first branch checked, not `PropertySkip`. Current emit checks `PropertySkip` first (early exit), correct for sparse-property streams (schema evolution), suboptimal for dense streams. Profile both orderings.
- Disasm-baseline project (`AyCode.Core.Benchmarks.Disasm`): structure-only or also a perf companion to BDN? Decide post-baseline.

## Future review — context shape + attribute strategy

LLM-only notes. Not a TODO entry — capture for a later audit pass when refactoring stabilizes.

### Context thinning

- Audit field layout of `BinarySerializationContext<TOutput>` + `BinaryDeserializationContext<TInput>`: hot fields (`_buffer` / `_position` / `_bufferLength`) first cache line; cold (`_stringCache`, `_internCache`, intern maps) separate.
- Generic specialization multiplier: every field × `<TInput>` / `<TOutput>` specialization count → field-add cost scales.
- Feature-gated fields removable when matching `Enable*Feature` consistently false across consumers (cross-ref ACBIN-T-K9M3 Phase C).

### Attribute strategy

| Pattern | When | Rationale |
|---|---|---|
| `[AggressiveInlining]` | ≤ ~5 IL body, branch-free, single-statement | High inline confidence |
| `[AggressiveOptimization]` | Warmup-sensitive hot loop, needs Tier-1 direct | Skip Tier-0 latency |
| `[NoInlining]` | Cold throw-helpers, rare-marker decoders, slow paths | Prevent caller-bloat |
| (no attribute) | All else | Let JIT decide |

- Audit drift: `[AggressiveInlining]` bodies may have grown past inline-threshold after later edits.
- Explicit `[NoInlining]` on `Throw*` / `*Slow` / exception formatters / coldpath helpers.
- Hint ≠ guarantee. Ötlet A regression confirms: complex bodies bail despite hint; the only reliable inlining is small bodies (≤ ~5 IL) OR inline emit (no method boundary).

### Context split — Strict vs Hybrid (larger refactor)

Single `BinaryDeserializationContext<TInput>` currently carries 100% hybrid-mode code (FixObj wrapper slot table, `GetWrapper(Type, byte)`, `_wrapperSlots[]`, `_nextRuntimeSlot`, TypeReaderTable bridge). Strict-mode SGen paths drag this dead weight.

**Target hierarchy** (inheritance without virtuals — derived adds fields/methods, never overrides):

```
BinaryDeserializationContextBase<TInput>     // common: buffer, ReadByte/Var*, ReadString*, intern, ScanInternString, FastWire
    BinaryDeserializationContextStrict<TInput>   // empty initially; future strict-only optimizations
    BinaryDeserializationContextHybrid<TInput>   // wrapperSlots[], GetWrapper(Type,byte), _nextRuntimeSlot, TypeReaderTable bridge
```

**Dispatch:** SGen type-analysis decides strict-or-hybrid at compile time (all reachable types `[AcBinarySerializable]` → strict; any non-marked → hybrid). `Deserialize<T>` creates the matching context type; `IGeneratedBinaryReader<TInput, TContext>` generic over context type.

**Win on strict path:**
- Complex emit per-property: -3 lines (FixObj slot fallback dead-coded out)
- Heap alloc per Deserialize: ~30-40 B smaller context
- Native code per `<TInput>` strict-spec: less branch, less i-cache
- Cross-type populate fallback paths physically absent → wire-corruption surfaces as missing-method instead of silent fallback

**Refactor sequence:**
1. Extract `Base<TInput>` (everything strict-needed: buffer state, primitives, intern cache, FastWire flag)
2. Move hybrid-only state/methods to `Hybrid<TInput>` derived
3. `Strict<TInput>` = empty derived initially (=== Base)
4. SGen emit: type-analysis picks Strict-emit or Hybrid-emit path; emit references typed concrete context
5. Reader interface generic-context-paraméter: `IGeneratedBinaryReader<TInput, TContext>`
6. Test reorganization: existing tests run on hybrid-context; add strict-graph fixtures

**Effort:** ~1-2 days. Symmetric work on `BinarySerializationContext` writer side. Gated on stable baseline (Ötlet A regression resolved first).

### Static-name ASCII-cache + UTF-16-raw fallback

Type-names (polymorphism path) and property-names (UseMetadata=true) are **static** — known at metadata-construction time. `Ascii.IsValid` scan once → cache as `bool` on metadata; SGen W9F1 emits compile-time const.

Wire path bypasses UTF-8 entirely:
- ASCII (common): marker + length + 1 B/char → byte-widen on read (`ReadAsciiBytesAsString`-like)
- Non-ASCII (rare): marker + length + 2 B/char UTF-16 raw memcpy → `MemoryMarshal.Cast<byte,char>` on read

Eliminates `ReadPlainString` / `ReadStringUtf8(int length)` / `DecodeUtf8(int byteLength)` (legacy 2-pass BCL chain, currently used only by polymorphism). Wire-format change scoped to `ObjectWithTypeName` / `ObjectWithTypeNameRefFirst` markers; user-string-property values (H2Q6 markers, runtime-arrived) unaffected.

## Cross-references

- `BINARY_TODO.md#accore-bin-t-k9m3` — wire-codec hoist + Phase C feature-conditional emit (sister work).
- `BINARY_SGEN.md` — SGen architecture, hybrid execution, bridge methods, wrapper slots.
- `BINARY_FORMAT.md` — wire-format markers referenced above.
- `Test_Benchmark_Results/Benchmark/Console.FullBenchmark_Release_2026-05-15_19-40-53.LLM` — most recent post-refactor baseline (pre-optimization).
