documentation/NETFramework-NGEN.md
NGEN is the name of the legacy native AOT technology used in .NET Framework. Compared to its modern .NET counter-part, NGEN has the following key characteristics:
Check the Ngen.exe (Native Image Generator) Microsoft Learn article for an overview of how NGEN works.
Visual Studio use NGEN for almost everything that ships in the box. The sheer amount of code which needs to be compiled makes it impractical for native image generation to occur synchronously during installation. Instead, VS installer queues up assemblies for deferred compilation by the NGEN service, which typically happens when the machine is idle. To force native images to be generated, one can execute the following command in an elevated terminal window:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ngen eqi
The .NET Framework build of MSBuild is inserted into VS and it registers itself for NGEN by including vs.file.ngenApplications
in the relevant files.swr entries. MSBuild
is hosted in several processes, most notably the stand-alone command line tool MSBuild.exe and the main IDE process devenv.exe.
Because each process runs with slightly different dependencies - MSBuild.exe loads most of them from [VS install dir]\MSBuild\Current\Bin
or [VS install dir]\MSBuild\Current\Bin\[arch] while devenv.exe has its own set loaded from other parts of the VS installation -
we NGEN our code twice. This is encoded by multiple vs.file.ngenApplications entries for a single file in files.swr.
The special [installDir]\Common7\IDE\vsn.exe entry represents devenv.
The bad thing about this is that the system is fragile and adding a dependency often results in having to tweak files.swr or
devenv.exe.config, the latter of which is generated from the file named devenv.urt.config.tt in the VS source tree. The good
thing is that regressions, be it a failure to compile an NGEN image or a failure to use an NGEN image, are reliably detected
by the VS PR gates so they are fixed before MSBuild is inserted into the product.
The Common Language Runtime can be finicky about allowing a native image to load. We usually speak of "NGEN rejections" where a native image has successfully been created but it cannot be used at run-time. When it happens, the CLR falls back to loading the IL assembly and JITting code on demand, leading to sub-optimal performance.
One major reason why a native image is rejected is loading into the LoadFrom context. The rules are excruciatingly complex, but suffice
it to say that when an assembly is loaded by Assembly.LoadFrom, it is automatically disqualified from having its native image used.
This is bad news for any app with an add-in system where extension assemblies are loaded by path.
One class of assemblies loaded by MSBuild by path are SDK resolvers. MSBuild scans the SdkResolvers subdirectory to discover
the set of resolvers to use when evaluating projects. Extensible in theory, though in reality only a couple of resolvers actually
exist. Because resolvers ship as part of VS, it is not difficult to make sure their assemblies are properly NGENed. The hard part is
loading them with the regular Assembly.Load so the native images can be used. MSBuild cannot simply extend its probing path to
include the relevant subdirectories of SdkResolvers because 1) It is outside of the app directory cone for amd64 and arm64 versions
of MSBuild.exe and 2) Not all resolvers actually live under this directory; the system allows them to be placed anywhere.
It is unfortunately also not straightforward to add a binding redirect with a codeBase
entry pointing to the right assemblies, because this requires knowing the exact assembly versions.
This is the most-commonly-used resolver, capable of resolving "in-box" SDKs that ship with the .NET SDK and .NET SDK workloads. Since the resolver assembly
is located at a known path relative to MSBuild and has very few dependencies, none of which are used anywhere else, we have decided to
freeze the version of the resolver plus dependencies, so that their full names can be specified in MSBuild.exe.config, e.g.
<dependentAssembly>
<assemblyIdentity name="Microsoft.DotNet.MSBuildSdkResolver" culture="neutral" publicKeyToken="adb9793829ddae60" />
<codeBase version="8.0.100.0" href=".\SdkResolvers\Microsoft.DotNet.MSBuildSdkResolver\Microsoft.DotNet.MSBuildSdkResolver.dll" />
</dependentAssembly>
Additionally, MSBuild.exe.config has the following entry, which enables us to refer to the resolver by simple name.
<qualifyAssembly partialName="Microsoft.DotNet.MSBuildSdkResolver" fullName="Microsoft.DotNet.MSBuildSdkResolver, Version=8.0.100.0, Culture=neutral, PublicKeyToken=adb9793829ddae60" />
This has a small advantage compared to hardcoding Microsoft.DotNet.MSBuildSdkResolver, Version=8.0.100.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
directly in the code, as it can be modified to work in non-standard environments just by editing the app config appropriately.
The resolver loading logic in MSBuild has been updated to call Assembly.Load(AssemblyName) where the AssemblyName specifies the
simple name of the assembly, e.g. Microsoft.DotNet.MSBuildSdkResolver, as well as its CodeBase (file path). This way the CLR assembly
loader will try to load the assembly into the default context first - a necessary condition for the native image to be used - and fall back
to LoadFrom if the simple name wasn't resolved.
The NuGet resolver has many dependencies and its version is frequently changing, so the technique used for Microsoft.DotNet.MSBuildSdkResolver
does not apply in its current state. However, the NuGet team is looking to address this by:
When this happens, the cost of JITting Microsoft.Build.NuGetSdkResolver will be eliminated as well.
When evaluating certain property functions, MSBuild requires functionality from NuGet.Frameworks.dll, which is not part of MSBuild proper.
The assembly is loaded lazily from a path calculated based on the environment where MSBuild is running and the functionality is invoked
via reflection. Similar to the NuGet resolver, the version is changing and it is not easy to know it statically at MSBuild's build time.
But, since there are only a handful of APIs used by MSBuild and they take simple types such as strings and versions, this has been
addressed by loading the assembly into a separate AppDomain. The AppDomain's config file is created in memory on the fly to contain the
right binding redirects, allowing MSBuild to use Assembly.Load and get the native image loaded if it exists.
This approach has some small startup cost (building the config, creating AppDomain & a MarshalByRefObject) and a small run-time overhead
of cross-domain calls. The former is orders of magnitude smaller that the startup hit of JITting and the latter is negligible as long as
the types moved across the AppDomain boundary do not require expensive marshaling. Additionally, the requirement to execute code in multiple
AppDomains necessitates the use of LoaderOptimization.MultiDomain for loading all assemblies domain-neutral. This may come with run-time
cost for certain code patterns, although none has been measured in MSBuild scenarios.
This is the proverbial elephant in the room. MSBuild learns about tasks dynamically as it parses project files. The UsingTask
element tends to specify the AssemblyFile attribute, pointing to the task assembly by path. Consequently MSBuild uses
Assembly.LoadFrom and no native images are loaded. Even task assemblies located in the SDK are problematic because MSBuild is
paired with an SDK on users machine at run-time. Unlike SDK resolvers and NuGet.Frameworks, which are part of the same installation
unit, this is a true dynamic inter-product dependency. Additionally, the task API is complex and involves a lot of functionality
provided to tasks via callbacks (e.g. logging) so the overhead of cross-domain calls may be significant. And that's assuming that
suitable native images exist in the first place, something that both VS and SDK installers would need to handle (task assemblies
in each installed SDK would need to be NGENed against each installed version of VS).
Hosting task assemblies in separate AppDomains looks like a major piece of work with uncertain outcome. We haven't tried it yet and most task code is JITted.