docs/design/tools/illink/compiler-generated-code-handling.md
Modern compilers provide language features which require lot of fancy code generation by the compiler. Not just pure IL generation, but producing new types, methods and fields. An example is async/await in C# which turns the body of the method into a separate class which implements a state machine.
Lot of the trimming logic relies on attributes authored by the developer. These provide hints to the trimmer especially around areas which are otherwise problematic, like reflection. For example see reflection-flow for an example of such attribute.
User authored attributes are not propagated to the compiler generated code. For example (using C# async feature):
[RequiresUnreferencedCode ("--MethodRequiresUnreferencedCode--")]
static void MethodRequiresUnreferencedCode () { }
[UnconditionalSuppressMessage ("IL2026", "")]
static async void TestBeforeAwait ()
{
MethodRequiresUnreferencedCode ();
await AsyncMethod ();
}
This code should not produce any warning, because the IL2026 is suppressed via an attribute. But currently this will produce the IL2026 warning from a different method:
ILLink: Trim analysis warning IL2026: SuppressWarningsInAsyncCode.<TestBeforeAwait>d__1.MoveNext(): Using method 'MethodRequiresUnreferencedCode()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Note that the warning comes from a compiler generated method MoveNext on class <TestBeforeAwait>d__1. The UnconditionalSuppressMessage attribute is not propagated and so from a trimmer perspective this is completely unrelated code and thus the warning is not suppressed.
The trimmer currently recognizes two attributes which effectively apply to entire method body:
RequiresUnreferencedCodeAttributeThe RequiresUnreferencedCodeAttribute marks the method as incompatible with trimming and at the same time it suppressed trim analysis warnings from the entire method's body. So for example (using C# iterator feature):
[RequiresUnreferencedCode ("Incompatible with trimming")]
static IEnumerable<int> TestBeforeIterator ()
{
MethodRequiresUnreferencedCode ();
yield return 1;
}
Should not produce a warning.
UnconditionalSuppressMessageAttributeThe UnconditionalSuppressMessageAttribute can target lot of scopes, but the smallest one is a method. It can't target specific statements within a method. It is supposed to suppress a specific warning from the method's body, for example:
[UnconditionalSuppressMessage("IL2026", "")]
static async void TestAfterAwait ()
{
await AsyncMethod ();
MethodRequiresUnreferencedCode ();
}
Should not produce a warning.
The trimmer performs data flow analysis within a single method's body, mostly around track the flow of System.Type and related instances to be able to detect recursion usage.
DynamicallyAccessedMembersAttributeThe DynamicallyAccessedMembersAttribute annotates values (local variables, method parameters, ...) of type System.Type to hint the trimmer that the type will have its methods accessed dynamically (through reflection). Such annotation doesn't propagate currently. For example:
static IEnumerable<int> TestParameter ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
type.GetMethod ("BeforeIteratorMethod");
yield return 1;
type.GetMethod ("AfterIteratorMethod");
}
Should not produce any warnings, because the type variable is properly annotated.
The trimmer also intrinsically recognizes certain patterns and perform data flow analysis around them. This allows full analysis of certain reflection usage even without annotations. For example, intrinsic handling of the typeof keyword:
static IEnumerable<int> TestLocalVariable ()
{
Type type = typeof (TestType);
type.GetMethod ("BeforeIteratorMethod");
yield return 1;
type.GetMethod ("AfterIteratorMethod");
}
To address the challenges of propagating user-authored attributes to compiler-generated code, .NET 10 introduced a general mechanism: [CompilerLoweringPreserveAttribute]. This attribute can be applied to other attribute types to instruct compilers to propagate those attributes to compiler-generated code.
DynamicallyAccessedMembersAttribute is now marked with [CompilerLoweringPreserve], so when the compiler generates new fields or type parameters (such as for local functions, iterator/async state machines, or primary constructor parameters), the relevant DynamicallyAccessedMembers annotations are automatically applied to the generated members. This allows trimming tools to directly use the annotations present in the generated code, without needing to reverse-engineer the mapping to user code.
For .NET 10 and later, trimming tools should rely on the compiler to propagate attributes such as DynamicallyAccessedMembersAttribute to all relevant compiler-generated code, as indicated by [CompilerLoweringPreserve]. No heuristics are needed for these assemblies. This isn't perfect because it's possible for such assemblies to be compiled with new Roslyn versions that could use different lowering strategies, so it's possible that the existing heuristics will break for new releases of a pre-net10.0 assembly.
To mitigate this there are a few options:
net10.0 (so that it is built with the new CompilerLoweringPreserve behavior and will avoid the heuristics)DynamicallyAccessedMembersAttribute type with CompilerLoweringPreserve. When present this would turn off the heuristics for the containing assembly.Another issue is that .NET 10 libraries might be built with <GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>, and the tooling would not be able to detect the TargetFramework. Aside from setting <GenerateTargetFrameworkAttribute>true</GenerateTargetFrameworkAttribute>, mitigations 1. and 2. above would also apply to this scenario.
Since the problems are all caused by compiler generated code, the behaviors depend on the specific compiler in use. The main focus of this document is the Roslyn C# compiler right now. Mainly since it's by far the most used compiler for .NET code. That said, we would like to design the solution in such a way that other compilers using similar patterns could also benefit from it.
It is expected that other compilers (for example the F# compiler) might have other patterns which are also problematic for trimmers. These should be eventually added to the document as well, but may require new solutions not discussed in here yet.
In order to create a lambda method with captured variables, the Roslyn compiler will generate a closure class which stores the captured values and the lambda method is then generated as a method on that class. Currently compiler doesn't propagate attributes to the generated methods.
RequiresUnreferencedCode with lambda[RequiresUnreferencedCode ("--TestLambdaWithCapture--")]
static void TestLambdaWithCapture (int p)
{
Action a = () => MethodRequiresUnreferencedCode (p);
}
Trimmer should suppress trim analysis warnings due to RequiresUnreferencedCode even inside the lambda. In C# 10 it will be possible to add an attribute onto the lambda directly. The attribute should be propagated only if it's not already there.
Open question Q1a: Should method body attributes propagate to lambdas? Maybe we should rely on C# 10 and explicit attributes only.
UnconditionalSuppressMessage with lambda[UnconditionalSuppressMessage ("IL2026", "")]
static void TestLambdaWithCapture (int p)
{
Action a = () => MethodRequiresUnreferencedCode (p);
}
Trimmer should suppress IL2026 due to the suppression attribute. In C# 10 it will be possible to add an attribute onto the lambda directly. The attribute should be propagated only if it's not already there.
Open question Q1a: Should method body attributes propagate to lambdas? Maybe we should rely on C# 10 and explicit attributes only.
static void TestParameterInLambda ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
Action a = () => {
type.GetMethod ("InLambdaMethod");
};
}
Trimmer should be able to flow the annotation from the parameter into the closure for the lambda and thus avoid warning in this case.
static void TestLocalVariableInLambda ()
{
Type type = typeof (TestType);
Action a = () => {
type.GetMethod ("InLambdaMethod");
};
}
Internal data flow tracking should propagate into lambdas.
static void TestGenericParameterInLambda<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T> ()
{
Action a = () => {
typeof (T).GetMethod ("InLocalMethod");
};
}
Annotations should flow with the generic parameter into lambdas.
RequiresUnreferencedCode with local function[RequiresUnreferencedCode ("--TestLocalFunctionWithNoCapture--")]
static void TestLocalFunctionWithNoCapture ()
{
LocalFunction ();
void LocalFunction()
{
MethodRequiresUnreferencedCode ();
}
}
The trimmer could propagate the RequiresUnreferencedCode to the local function. Unless the function already has that attribute present.
Question Q1b: Should method body attributes propagate to local functions? It's possible to add the attribute manually to the local function, so maybe we should simply rely on that.
UnconditionalSuppressMessage with local function[UnconditionalSuppressMessage ("IL2026", "")]
static void TestLocalFunctionWithNoCapture ()
{
LocalFunction ();
void LocalFunction ()
{
MethodRequiresUnreferencedCode ();
}
}
Similarly to the A5 case, the trimmer could propagate the warning suppression to the local function. Unless the function already has suppressions. Question Q1b: Should method body attributes propagate to local functions? It's possible to add the attribute manually to the local function, so maybe we should simply rely on that.
static void TestParameterInLocalFunction ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
LocalFunction ();
void LocalFunction ()
{
type.GetMethod ("InLocalMethod");
}
}
Identical to A3, annotations should propagate into local functions.
static void TestLocalVariableInLocalFunction ()
{
Type type = typeof (TestType);
LocalFunction ();
void LocalFunction ()
{
type.GetMethod ("InLocalMethod");
}
}
Identical to A4 - Internal data flow tracking should propagate into local functions.
static void TestGenericParameterInLocalFunction<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T> ()
{
LocalFunction ();
void LocalFunction ()
{
typeof (T).GetMethod ("InLocalMethod");
}
}
Generic parameter annotations should flow into local methods.
Specifically the C# compiler will rewrite entire method bodies. Iterators which return enumeration and use yield return will rewrite entire method body and move it into a separate class. This has similar problems as closures since it effectively behaves a lot like closure, but has additional challenges due to different syntax.
RequiresUnreferencedCode with iterator body[RequiresUnreferencedCode ("--TestAfterIterator--")]
static IEnumerable<int> TestAfterIterator ()
{
yield return 1;
MethodRequiresUnreferencedCode ();
}
The attribute should apply to the entire method body and thus suppress trim analysis warnings. Even if the body is spread by the compiler into different methods.
UnconditionalSuppressMessage with iterator body[UnconditionalSuppressMessage ("IL2026", "")]
static IEnumerable<int> TestBeforeIterator ()
{
MethodRequiresUnreferencedCode ();
yield return 1;
}
The attribute should apply to the entire method body and thus suppress trim analysis warnings. Even if the body is spread by the compiler into different methods.
static IEnumerable<int> TestParameter ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
type.GetMethod ("BeforeIteratorMethod");
yield return 1;
type.GetMethod ("AfterIteratorMethod");
}
The data flow annotation from method parameter should flow through the entire body.
static IEnumerable<int> TestLocalVariable ()
{
Type type = typeof (TestType);
type.GetMethod ("BeforeIteratorMethod");
yield return 1;
type.GetMethod ("AfterIteratorMethod");
}
The intrinsic annotations should flow through the entire body.
static IEnumerable<int> TestGenericParameter<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T> ()
{
typeof (T).GetMethod ("BeforeIteratorMethod");
yield return 1;
typeof (T).GetMethod ("AfterIteratorMethod");
}
Generic parameter annotations should flow through the entire body.
Similarly to iterators, C# compiler also rewrites method bodies which use async/await. This has similar problems as closures since it effectively behaves a lot like closure, but has additional challenges due to different syntax.
RequiresUnreferencedCode with async body[RequiresUnreferencedCode ("--TestAfterAwait--")]
static async void TestAfterAwait ()
{
await AsyncMethod ();
MethodRequiresUnreferencedCode ();
}
The attribute should apply to the entire method body and thus suppress trim analysis warnings. Even if the body is spread by the compiler into different methods. Very similar to B1.
UnconditionalSuppressMessage with iterator body[UnconditionalSuppressMessage("IL2026", "")]
static async void TestBeforeAwait()
{
MethodRequiresUnreferencedCode ();
await AsyncMethod ();
}
The attribute should apply to the entire method body and thus suppress trim analysis warnings. Even if the body is spread by the compiler into different methods. Very similar to B2.
static async void TestParameter ([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
type.GetMethod ("BeforeAsyncMethod");
await AsyncMethod ();
type.GetMethod ("AfterAsyncMethod");
}
The data flow annotation from method parameter should flow through the entire body. Very similar to B3.
static async void TestLocalVariable ()
{
Type type = typeof (TestClass);
type.GetMethod ("BeforeAsyncMethod");
await AsyncMethod ();
type.GetMethod ("AfterAsyncMethod");
}
The intrinsic annotations should flow through the entire body. Very similar to B4.
static async void TestGenericParameter<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T> ()
{
typeof (T).GetMethod ("BeforeIteratorMethod");
await AsyncMethod ();
typeof (T).GetMethod ("AfterIteratorMethod");
}
Generic parameter annotations should flow through the entire body. Very similar to B5.
When the Roslyn compiler generates a closure and moves the code in the method into a method on the closure trimming tools should have enough information to provide correct information about the source of a warning (if there's any).
static void TestInLambda ()
{
Action a = () => MethodRequiresUnreferencedCode (); // Warning should point to this line
}
Given symbols this should produce a warning pointing to the source file, but should not include the method name which looks like <>c.<TestInLambda>b__1_0(). This should produce a warning which looks like:
Source.cs(3,22): Trim analysis warning IL2026: Using method 'MethodRequiresUnreferencedCode()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Similarly for all other warnings originating from trim analysis.
Open question Q2a: Should we try to display a better name for lambdas if there are no symbols available for the assembly?
static void TestInLocalFunction ()
{
LocalFunction ();
void LocalFunction()
{
MethodRequiresUnreferencedCode (); // Warning should point to this line
}
}
Just like with lambdas, if there are symbols the warning should point to the source location and not include the compiler generated method name which in this case looks something like Type.<TestInLocalFunction>g__LocalFunction|2_0(). The produced warning should look like this:
Source.cs(7,9): Using method 'MethodRequiresUnreferencedCode()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Open question Q2b: Should we try to display a better name for local functions if there are no symbols available for the assembly?
static IEnumerable<int> TestIterator ()
{
MethodRequiresUnreferencedCode (); // Warning should point to this line
yield return 1;
}
In this case as well, the trimmer should report the warning pointing to the source and avoid including the compiler generated name which looks like <TestIterator>d__3.MoveNext(). The warning should look like this:
Source.cs(3,5): Using method 'MethodRequiresUnreferencedCode()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Open question Q2c: Should we try to display a better name for iterator methods if there are no symbols available for the assembly?
static async void TestAsync ()
{
MethodRequiresUnreferencedCode ();
await AsyncMethod ();
}
Just like in the above cases the warning should point to source and not include the compiler generated name, which looks like <TestAsync>d__4.MoveNext(). The warning should look like:
Source.cs(3,5): Using method 'MethodRequiresUnreferencedCode()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Open question Q2d: Should we try to display a better name for async methods if there are no symbols available for the assembly?
Data flow analysis in the trimmer reports warnings which point to sources of data values, for example method parameters, fields and so on. With closure classes generated by the compiler the warning might be in a spot where the immediate value is read from a field on the closure class, but that field is compiler generated. Ideally the warning would point to the actual source of the value for that field which is visible in user code.
static void TestWarningInLambda (Type typeParameter)
{
Action a = () => typeParameter.GetMethod ("InLambdaMethod");
}
The above should produce warning blaming typeParameter as the source of the unannotated value.
static void TestWarningInLocalFunction<TInput> ()
{
Action a = () => typeof(TInput).GetMethod ("InLambdaMethod");
}
In this case the warning should blame TInput as the unannotated value.
static IEnumerable<int> TestWarning(Type typeParameter)
{
typeParameter.GetMethod ("InIteratorMethod");
yield return 1;
}
This should produce warning blaming typeParameter.
static async void TestWarning<TInput>()
{
typeof (TInput).GetMethod ("InAsyncMethod");
await AsyncMethod ();
}
And this one should blame TInput.
The solution needs to solve two problems:
RequiresUnreferencedCode suppression from
the user method to all of the compiler generated methods/types which are called by that method.
This is called "suppression propagation" below.In both cases the first thing the trimmer needs to figure out is for a given user method what are all of the compiler generated items which were used to implement that method in the IL. This can include:
To correctly handle warning suppression, the trimmer needs to apply the same warning suppressions on warnings generated due to all of the compiler generated items as if those were coming directly from the user method's body.
To correctly handle data flow analysis, the trimmer needs to be able to track values across all of the compiler generated items as if they implement a single unit. This almost inevitable leads to cross method data flow analysis. Since that is a very expensive thing to do, being able to confine it to only the compiler generated code for a given user method is almost necessary. On top of that the trimmer needs to be able to track values as they traverse local variables, method parameters and fields on the closure types.
Detecting which compiler generated items are used by any given user method is currently relatively tricky. There's no definitive marker in the IL which would let the trimmer confidently determine this information for all of the above cases. Good long term solution will need the compilers to produce some kind of marker in the IL so that static analysis tools can reliably detect all of the compiler generated items.
This ask can be described as: For a given user method, ability to determine all of the items (methods, fields, types, IL code) which were used by the compiler to generate the functionality of the method into an IL assembly.
This should be things which are directly generated from the user's code. It should NOT include compiler helpers and other infrastructure which may be needed but is not directly attributable to a user code.
This should be enough to implement solutions for both suppression propagation and data flow analysis.
For DynamicallyAccessedMembersAttribute, we have a long-term solution that relies on the
[CompilerLoweringPreserve] attribute, which tells Roslyn to propagate DynamicallyAccessedMembers
annotations to compiler-generated code.
Without compiler provided markers, there's no existing way to 100% reliably implement the required functionality in the trimmer. That said, it should be possible to implement a reasonably good approximation. This approximation will necessarily make assumptions about the compilers used to produce the analyzed code. So for the purposes of a short term solution, Roslyn CSharp compiler should be treated as first priority (since it's by far the most common compiler to produce analyzed IL). Second in row should be the FSharp compiler.
It is also much simpler to implement suppression propagation, so that should be done first.
The general idea how to detect compiler generated code for a given user method:
< and > characters in the identifiers
of a compiler generated items. In FSharp this role is served by the @ character.Pros:
Cons:
DynamicallyAccessedMemberType.All) and the actual
user method is not marked at all then there's no good way to determine the user method to figure out suppression
context. Again can lead to reporting less warnings.For state machines the Roslyn compiler currently generates IteratorStateMachineAttribute, AsyncStateMachineAttribute and AsyncIteratorStateMachineAttribute attributes onto the "user method" and the attribute points to the generated state machine type.
This can be used to completely deterministically figure out the mapping between state machine code and user methods.
Pros:
Cons:
The recommended solution is to use the deterministic approach via attributes. This solution doesn't have the reflection access problem (which is very problematic). The fact that it doesn't solve lambdas and local functions has a reasonable workaround. Both can be annotated manually by the developer by adding attributes to them. This introduces a discrepancy between analyzer and trimmer (as analyzer would not have this problem), but it's acceptable behavior for .NET 6.