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:
1 |
dotnet new console -o HelloWorld |
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.
1 |
dotnet publish HelloWorld -c Release |
Framework-dependent executable:
Framework-dependent executable produces a platform-specific executable that uses the locally installed .NET runtime.
1 |
dotnet publish HelloWorld -c Release -r win-x64 --self-contained false |
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.
1 |
dotnet publish HelloWorld -c Release -r win-x64 --self-contained true |
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!
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.
1 |
dotnet publish HelloWorld -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true |
It is not exactly a single executable. All runtime-related DLLs are required to run this executable. The overall weight is 58.9 MB.
As explained above this executable cannot be a .NET assembly. It needs to bootstrap the .NET runtime. You cannot gather metadata from it.
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:
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.
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:
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.
1 2 3 4 5 6 7 8 9 10 11 12 |
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <RootNamespace>HelloWorld1</RootNamespace> <PublishSingleFile>true</PublishSingleFile> <PublishReadyToRun>true</PublishReadyToRun> <PublishTrimmed>true</PublishTrimmed> <TrimMode>CopyUsed</TrimMode> <RuntimeIdentifier>win-x64</RuntimeIdentifier> </PropertyGroup> </Project> |
.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.
1 |
C:\Users\pat\source\repos\HelloWorld1>dotnet publish HelloWorld1 -r win-x64 -c Release /p:PublishSingleFile=true -p:PublishTrimmed=True -p:TrimMode=CopyUsed --self-contained true |
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 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!
1 |
C:\Users\pat\source\repos\HelloWorld1>dotnet publish HelloWorld1 -r win-x64 -c Release /p:PublishSingleFile=true -p:PublishTrimmed=True -p:TrimMode=Link --self-contained true |
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:
1 2 3 4 5 6 |
// <Name>Direct and indirect callees of the class Program</Name> from t in Types let depth0 = t.DepthOfIsUsedBy("HelloWorld.Program") where new Range(1,100).ContainsAny(depth0) orderby depth0 select new { t, depth0, t.NbILInstructions } |
And here is the graph generated:
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.
1 2 3 4 5 6 7 |
// <Name>Direct and indirect callees of 2 methods</Name> from m in Methods let depth0 = m.DepthOfIsUsedBy("HelloWorld.Program.Main(String[])") let depth1 = m.DepthOfIsUsedBy("HelloWorld.Program..ctor()") where new Range(1,100).ContainsAny(depth0, depth1) orderby depth0 select new { m, depth0, depth1, m.NbILInstructions } |
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!