NDepend Blog

Improve your .NET code quality with NDepend

.NET 5.0 App Trimming and Potential for Future Progress

September 28, 2020 7 minutes read

In this article we will:

  • go through the various ways to publish a .NET 5.0 application,
  • play with .NET 5.0 app trimming to reduce the size of our deliverable
  • use both Visual Studio 2019 and the dotnet command to achieve app trimming
  • shed light on potential for significant App Trimming progress

Let’s first create a HelloWorld application with the dotnet command that we will use for our experiments:

Ways to publish a .NET 5.0 application with the.NET command

.NET Core and now .NET 5.0 propose several ways to publish an application:

Framework-dependent deployment

Framework-dependent deployment produces a cross-platform .dll file that uses the locally installed .NET runtime.

Framework dependent deployment
Framework dependent deployment

Framework-dependent executable:

Framework-dependent executable produces a platform-specific executable that uses the locally installed .NET runtime.

Framework dependent executable
Framework dependent executable

Note that in both framework-dependent scenarios, HelloWorld.dll is a regular .NET assembly. HelloWorld.exe is here to locate the corresponding .NET install on the machine and bootstrap the .NET runtime. Then the .NET runtime loads and runs HelloWorld.dll. Obviously HelloWorld.exe is not a .NET assembly.

Let’s precise that a .NET Framework v4.x .exe assembly doesn’t need such side executable because Windows knows how to bootstrap the .NET v4.x runtime when starting a .NET v4.x assembly.

Self-contained executable

Self-contained executable produces a platform-specific executable and includes a local copy of the .NET Core runtime and BCL.

Self-contained executable is appealing for a numbers of scenarios. But it comes with a significant drawback: for the simplest HelloWorld application it produces 225 files that weight overall 65MB!

Publish self-contained HelloWorld weights 65MB!
Publish self-contained HelloWorld weights 65MB!

Self-contained single file executable

Self-contained single file executable produces a platform-specific executable and embeds a local copy of the .NET Core runtime in the executable.

It is not exactly a single executable. All runtime-related DLLs are required to run this executable. The overall weight is 58.9 MB.

Publish single executable
Publish self-contained single executable

As explained above this executable cannot be a .NET assembly. It needs to bootstrap the .NET runtime. You cannot gather metadata from it.

Publish single executable: Not a valid .NET assembly
Publish single executable: Not a valid .NET assembly

Publish a .NET 5.0 application with Visual Studio 2019

For those experiments I use Visual Studio 2019 16.8 Preview 3. An executable project can be published from Visual Studio thanks to the right-click menu Publish:

Visual Studio Project Publish
Visual Studio Project Publish

Here I’ll publish to a local folder. The Edit menu gives access to Publish settings that mirror the dotnet command arguments we’ve seen.

Visual Studio Project Publish Edit Settings
Visual Studio Project Publish Edit Settings

One of the setting we haven’t covered yet is Enable ReadyToRun compilation (R2R). This setting aimed at improving the startup performance of the application. For that it precompiles the managed code (IL code) to its native form. Doing so reduces the JIT work at startup-time. This is Ahead-of-Time compilation (AOT).

Note that these publish settings are stored in the file FolderProfile.pubxml file:

FolderProfile.pubxml
FolderProfile.pubxml

These settings can also be specified in the .csproj project file. However the <PublishXXX> settings are only taken account at publish time, not at compilation time.

.NET 5.0 App Trimming

One setting that we haven’t mentioned yet is TrimMode. It aims at helping with the size problem mentioned, 65MB or even 59MB for a HelloWorld application. This is not reasonable!

TrimMode CopyUsed

The TrimMode CopyUsed prunes unused framework assemblies out from the result.

CopyUsed trimming shrinks HelloWorld.exe from 52MB to 13MB leading to a 75% reduction! This is great but still quite heavy for a HelloWorld application.

TrimMode: CopyUsed
TrimMode: CopyUsed

TrimMode Link

To help with that concern .NET 5.0 introduces the new TrimMode value Link. This mode not only prunes unused framework assemblies but it also prunes some classes and members in the remaining assemblies that cannot be reached at application runtime. Now HelloWorld.exe weights 5.9MB, 89% reduction compared to 59MB no trimming!

TrimMode: Link
TrimMode: Link

The TrimMode Link is of great help. Especially in a new world where .NET assemblies are downloaded and executed in the browser through the promising Blazor WebAssembly technology.

The Challenge of Code Dynamic Resolved

However some users report that the Link trim mode makes their applications crash. Even Microsoft claims that with the Link mode you should perform an exhaustive testing of the app to ensure that nothing has been trimmed that could be required. 

Indeed, such Link mode cannot be ideal because it is based on static analysis. Static analysis cannot estimate perfectly which type is really used at runtime because of dynamic resolving. Typical dynamic-resolving situations are usage of reflection and some Dependency Injection frameworks.

The post Top 10 .NET 5.0 new APIs explains that some new attributes have been introduced in System.Diagnostics.CodeAnalysis. These attributes are here to feed static analyzers with information about dynamic resolving. Here are some of these new attributes:

  • UnconditionalSuppressMessageAttribute: Suppresses reporting of a specific rule violation, allowing multiple suppressions on a single code artifact. It is different than SuppressMessageAttribute in that it doesn’t have a ConditionalAttribute. It’s always preserved in the compiled assembly.
  • RequiresUnreferencedCodeAttribute: This attribute can tag constructor and methods. It Indicates that the specified method requires dynamic access to code that is not referenced statically, for example, through System.Reflection. This attribute allows tools to understand which methods are unsafe to call when removing unreferenced code from an application.
  • DynamicallyAccessedMembersAttribute: This attribute can tag some members (fields, methods…). It indicates that certain members on a specified type are accessed dynamically, for example, through System.Reflection.
  • DynamicDependencyAttribute: This attribute can be used to inform tooling of a dependency that is otherwise not evident purely from metadata and IL, for example, a member relied on via reflection. It can tag constructors, methods and fields.

Potential for Future Progress : Will App Trimming do better?

I hope – and bet – that app trimming will do better in the future. 6MB + 8MB of runtime is still quite heavy for the simplest HelloWorld. Compare this number with the advise that web pages should weight in the range 1.5MB – 3MB. We are still an order of magnitude higher for .NET code executed in the browser. Caching in browser the BCL assemblies helps a lot but the first load still requires a lot of bytes.

Type Call Graph

I analyzed the HelloWorld.dll and all .NET 5.0 framework DLLs assemblies with NDepend to obtain the call graph of the HelloWorld.Program class. I obtained 2 615 types for a total of 749K IL instructions.

Let’s count a few bytes per IL instruction + metadata leads to the formula: 749K IL instruction * 6 bytes * 120% = 5.3MB. We obtain a rough estimation which is coherent with the 6MB size of the Link trimmed HelloWorld.exe (assuming not many members are pruned).

The code query is:

And here is the graph generated:

Program Type Call Graph
HelloWorld.Program Type Call Graph

Method Call Graph

Let’s now generate the HelloWorld.Program.Main() and .ctor() methods and fields call graph. We obtain 210 methods matched for a total of 3 289 IL instructions. It means that only 0.4% of all managed code in the 2.615 types is statically reachable. Of course taking account of dynamic resolving can only increase these estimations. However, because of performance requirements, I don’t expect that core BCL types massively rely on dynamic resolving.

Program.Main() Method Call Graph
HelloWorld.Program.Main() and .ctor() Method Call Graph

Here is this complete graph in SVG vector format generated with the Export graph to SVG feature.

This HelloWorld example is minimal. It contains only 2 .NET BCL method calls: Console.WriteLine(String) and ReadKey(). However it shows that a fraction of types statically reachable is actually reachable. There are hopes for more aggressive trimming and significant size reduction. If Microsoft goes that way it will certainly mean for the runtime to deal with non-coherent managed code. For example we can imagine virtual methods pruned that should be here. This looks like quite challenging!

Conclusion

.NET Core introduced various ways to publish an application. .NET 5.0 capitalizes on those efforts and goes beyond.

Deployment embedding the BCL consumed is playing an increasing role in the .NET ecosystem. This is especially true since Blazor WebAssembly that downloads and run .NET assemblies in the browser.

With the new .NET 5.0 TrimMode Link we see that Microsoft invests in ways to reduce the size of published code. However because of dynamic-resolving this is complex.

The analysis made with the NDepend call graph capabilities shed light on potential for significant progress in this area.

Those who read my articles knows that I am an advocate of high code coverage ratio by tests (ideally 100%). One solid way to make significant progress with App Trimming would be to spy a close to exhaustive automatic test execution to collect what gets really executed in referenced code. This would make sense to perform such dynamic analysis to help solving the dynamic resolving challenge!