Skip to content

Use UnsafeAccessor (NET8+) and UnsafeAccessorType (NET10+) for non-public IL generator internals#510

Draft
Copilot wants to merge 4 commits intomasterfrom
copilot/use-unsafeaccess-net8-and-net10
Draft

Use UnsafeAccessor (NET8+) and UnsafeAccessorType (NET10+) for non-public IL generator internals#510
Copilot wants to merge 4 commits intomasterfrom
copilot/use-unsafeaccess-net8-and-net10

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 12, 2026

Accessing private fields of RuntimeILGenerator and DynamicScope (both internal .NET types) previously required generating DynamicMethod wrappers at startup via reflection. NET10's UnsafeAccessorType enables direct, zero-reflection access to these non-public types.

Changes

NET10+ — DynamicMethodHacks.GetNextLocalVarLocation (hot path)

Replaces the reflection-based DynamicMethod generation at startup with direct UnsafeAccessorType accessors:

// Before: generated a DynamicMethod at startup via reflection (~20 lines of IL emission)

// After (NET10+):
GetNextLocalVarLocation = static (il, t) =>
{
    GetMLocalSignature(il).AddArgument(t, false);
    return PostInc(ref GetMLocalCount(il));
};

Six new [UnsafeAccessorType] accessors added to DynamicMethodHacks targeting:

  • RuntimeILGenerator: m_localSignature, m_localCount, m_length, m_ILStream, UpdateStackSize
  • DynamicScope: m_tokens

Limitation: DynamicILGenerator.m_scope returns a DynamicScope (non-public type). Since UnsafeAccessorType cannot currently express non-public return types, m_scope remains reflection-accessed. m_tokens on the returned scope is then accessed via UnsafeAccessorType.

EmitHacksTest.cs — bug fixes + NET10+ demonstration

  • Bug fix: Field lookup was incorrectly targeting the public ILGenerator class (where m_length, m_ILStream, UpdateStackSize are not declared). Fixed to use DynamicILGenerator.BaseTypeRuntimeILGenerator.
  • Bug fix: UpdateStackSize delegate creation via Delegate.CreateDelegate fails for methods on non-public declaring types. Replaced with a DynamicMethod-based wrapper using skipVisibility: true.
  • Added DynamicMethod_Emit_Hack_Net10() demonstrating the clean UnsafeAccessorType path.
  • Enabled EmitHacksTest in the test runner (was commented out due to the above bugs).

Project targets

Added net10.0 to all TargetFrameworks and suppressed NU1510 (new .NET 10 SDK warning) globally.

Build and CI

  • Added net10.0 test run to build.bat (before net9.0).
  • Added net10.0 test step to .github/workflows/build.yml (before net9.0).

Copilot AI changed the title [WIP] Update to use UnsafeAccess in net8 and UnsafeAccessType in net10 Use UnsafeAccessor (NET8+) and UnsafeAccessorType (NET10+) for non-public IL generator internals Apr 12, 2026
Copilot AI requested a review from dadhi April 12, 2026 19:14
Copy link
Copy Markdown
Owner

@dadhi dadhi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add net10 to build.bat and ci.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 12, 2026

Add net10 to build.bat and ci.

Done in c0a8a45. Added net10.0 test runs to both build.bat (before net9.0) and .github/workflows/build.yml (before net9.0).

Copilot AI requested a review from dadhi April 12, 2026 19:39
Copy link
Copy Markdown
Owner

@dadhi dadhi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • address comments
  • general question - does usage of UAT attribute and such helps with aot?
  • what other places in fec we can apply it to and what are there potential wins?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot what are we testing in EmitHacks. Is it something that we already using in Fec or something notvyrt applied. If latter - can ee apply it now with the fixes in place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EmitHacksTest demonstrates direct IL stream manipulation — writing bytes straight to m_ILStream (and updating m_length/stack tracking via UpdateStackSize) instead of going through ILGenerator.Emit(). This is a future optimization path that is not yet applied to FEC's main emission loop.

What IS already applied in DynamicMethodHacks:

  • GetNextLocalVarLocation uses UAT (NET10+) / a startup-generated DynamicMethod (NET8–9) to access m_localSignature and m_localCount to track locals — bypassing the overhead of ILGenerator.DeclareLocal().

What EmitHacksTest proves but FEC doesn't yet use:

  • Bypassing ILGenerator.Emit() entirely and writing opcodes/operands directly into m_ILStream. This would be a significant refactor of every emission site in FEC and is best tracked as a separate performance issue (e.g. issue Hack DynamicMethod #351 is already referenced in the hack comments).

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 12, 2026

Add net10 to build.bat and ci.

Done in c0a8a45. Added net10.0 test runs to both build.bat (before net9.0) and .github/workflows/build.yml (before net9.0).

  • address comments
  • general question - does usage of UAT attribute and such helps with aot?
  • what other places in fec we can apply it to and what are there potential wins?

Does [UnsafeAccessorType] help with AOT?

Yes — it's actually one of the primary motivations. Standard FieldInfo.GetValue / MethodInfo.Invoke at call sites breaks Native AOT (the trimmer can't statically see what's accessed). [UnsafeAccessor] + [UnsafeAccessorType] replace that with zero-overhead, statically-resolved member access that the AOT compiler can see and preserve. Note that DynamicMethodHacks is still marked [RequiresUnreferencedCode] because the startup-time DynamicMethod generation (for ReuseDynamicILGenerator, NET8/9 GetNextLocalVarLocation, etc.) still uses reflection — that part isn't AOT-safe. The UAT accessors themselves are.

Other places in FEC where UAT could be applied and potential wins:

Current reflection usage UAT-able? Win
GetNextLocalVarLocation (NET8/9 path, DynamicMethod-generated) ✅ Already UAT in NET10+ Eliminates startup DynamicMethod codegen
DynamicMethod._ilGenerator field (ILGeneratorField) ✅ Yes — DynamicMethod is public Remove startup GetField
ILGenerator.Reset() / ReuseDynamicILGenerator wrapper ⚠️ Partly — method is on non-public RuntimeILGenerator UAT for the call itself; DynamicMethod host still needed for NET8/9
DynamicILGenerator.m_scope ❌ No — return type is non-public DynamicScope Can't express non-public return type yet
m_tokens on DynamicScope ✅ Already UAT in NET10+ Done
Direct m_ILStream writes (EmitHacksTest approach) ✅ Yes — already UAT in NET10+ test Full ILGenerator.Emit() bypass; tracked under #351

The biggest remaining win would be hooking up the direct IL stream writes (EmitHacksTest approach) to FEC's actual emission loop, but that's a larger refactor best done as a follow-on issue.

Copilot AI requested a review from dadhi April 12, 2026 19:53
@dadhi
Copy link
Copy Markdown
Owner

dadhi commented Apr 12, 2026

@copilot could you assess more on the wins of EmitHackTests approach explaining what is it?

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 12, 2026

@copilot could you assess more on the wins of EmitHackTests approach explaining what is it?

What the EmitHacksTest approach actually does

Normal ILGenerator.Emit(opcode, operand) does this work on every call:

  1. Looks up opcode.m_operandType and switches on it to determine how many bytes to write
  2. Calls EnsureCapacity(n) — checks whether m_ILStream needs to grow (allocation on overflow)
  3. Calls InternalEmit(opcode) — writes 1–2 bytes for the opcode
  4. Writes the operand bytes (for Call/Ldfld/etc., calls GetMethodToken()/GetFieldToken()DynamicScope.GetTokenFor() → list mutation)
  5. Calls UpdateStackSize(opcode, stackchange) — updates stack depth tracking

The hack approach skips steps 1–2 and replaces step 4 for member tokens:

// Normal path (per call: switch + bounds check + virtual dispatch + token lookup)
il.Emit(OpCodes.Call, meth);

// Hack path (after setup: direct byte write + list.Add)
mILStream[mLength++] = (byte)OpCodes.Call.Value;
mTokens.Add(meth.MethodHandle);                             // direct list write
var token = mTokens.Count - 1 | 0x06000000;
BinaryPrimitives.WriteInt32LittleEndian(mILStream.AsSpan(mLength), token);
mLength += 4;
UpdateStackSize_Net10(il, OpCodes.Call, stackChange);       // same as normal

Potential wins for FEC

FEC has ~482 il.Emit/il.Demit call sites across its emission paths. Each compiled lambda exercises a subset of them. The gains per-call are small individually, but FEC is explicitly a hot-path compiler:

What's eliminated Savings
opcode.m_operandType switch (per Emit) 1 branch + table lookup
EnsureCapacity check (per Emit) 1 compare + branch (predictable but present)
GetMethodToken/GetFieldTokenDynamicScope.GetTokenFor() → string-interned lookup for every Call/Ldfld/Stfld/Newobj Replaces with direct List<object>.Add
No-op stack change calls (e.g. Ret always has stackchange=0) Can be skipped entirely

The comments in FEC already point at exactly this (// todo: @perf #351): for pairs like Stloc_0 + Ldloca_S 0 in EmitStoreAndLoadLocalVariableAddress, the code already has commented-out sketches of what the direct-write version would look like but falls back to Demit.

The pre-sizing path (dynMethod.GetILGenerator(16)) FEC already uses means EnsureCapacity almost never reallocates — making EnsureCapacity a near-free check that the hack can still eliminate.

In practice, the biggest measurable win would be on the token-emitting opcodes (Call, Callvirt, Newobj, Ldfld, Stfld, Ldsfld, Stsfld) — these are the most frequent in FEC's output and each one currently goes through DynamicScope's token table. The hack replaces that with a direct List.Add + BinaryPrimitives.WriteInt32LittleEndian.

The reason it hasn't been applied yet is that it requires pre-computing the exact byte budget per expression tree node (to pre-size correctly and avoid the EnsureCapacity fallback path), and wiring the mILStream/mLength refs through all ~482 emit sites — a substantial but well-scoped refactor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use UnsafeAccess in net8+ and UnsafeAccessType in net10+

2 participants