Working with large .NET 5 solutions in Visual Studio 2019 16.8

Paul Vick

With the release of .NET 5, migration of solutions from .NET Framework has increased. In particular, we have started to see very large solutions being moved. To ensure this experience is as good as possible, we have been working on optimizing Visual Studio to handle solutions that contain large numbers of .NET 5 and .NET Core projects. Many of these optimizations were shipped in the 16.8 release, and this blog post walks through the improvements we’ve made.

Running the C# and VB compiler out of process

Roslyn, the C# and Visual Basic compiler, parses and analyzes the entire solution to power services such as IntelliSense, Go to Definition, and diagnostics/errors. As a result, Roslyn tends to consume resources that increase proportionally with the size of the open solution, which can get quite significant for large solutions. Roslyn has already worked to minimize this impact by aggressively caching on disk information that isn’t immediately required, but even with that caching, it cannot escape the need to keep data in memory.

To reduce its impact on larger Visual Studio solutions, the Roslyn team has spent considerable effort moving the Roslyn compiler out of the Visual Studio process and into its own process. Running Roslyn in its own process frees up resources within Visual Studio itself and allows the Roslyn compiler more room to do its work. For large solutions, this can save up to a third of the memory consumed by Visual Studio when you open a large solution.

Streamlining the Dependencies node

Every .NET 5 and .NET Core project has a node in the Solution Explorer named “Dependencies” that displays all the things that the project depends on: other projects, assemblies, NuGet packages, etc. In addition to showing the immediate dependencies of the project, the node also shows the transitive dependencies of the project, i.e., all the things each dependency itself depends on, and so on. With a project of any reasonable size, this list of transitive dependencies can get quite large.

Unfortunately, the original implementation of the “Dependencies” node was not particularly efficient in the way that it stored the transitive dependency information in memory. It held on to much more information than was needed, and much of the data was redundant. We rewrote the code to keep only information that was absolutely required, and we started piggybacking on the existing information on dependencies already kept by NuGet. This rewrite saved up to 10-15% of the memory consumed by Visual Studio when you open a large solution.

Reducing duplicate information in MSBuild

After Roslyn, one of the other major consumers of resources in a Visual Studio process is MSBuild. This is because, as the build engine, much of the IDE experience is powered by MSBuild’s object model. Although we’ve made project files themselves much smaller in .NET 5 and .NET Core, there are a significant number of supporting project files that get imported into projects through the SDK. Evaluating all those files is necessary to understand and build a project, and can consume up to another third of the memory consumed by Visual Studio when you open a large solution.

Although project files generate a lot of data, much of that data is repetitive, and we have started de-duplicating that data in memory. Strings are one of the most common byproducts of the project system, and they store information like file names, options, and paths. Paths, specifically, can be quite long and can end up consuming a lot of memory if there are too many of them, or if they are duplicated too many times. Ensuring that we keep only one copy of a string saved up to 5-10% of the memory consumed by Visual Studio when you open a large solution.

Reducing project copies held onto by the project system

One of the important design aspects of the .NET 5 and .NET Core project system is asynchrony. Often the project system needs to do work in response to a user action (for example, adding a new reference to a project). Instead of blocking Visual Studio while it finishes its work, the project system allows the user to continue working in the IDE and does the work in the background.

Because the user can continue to make changes to a project (say, adding yet another reference) while the project system is processing previous changes in the background, the project system must save snapshots of the project data to ensure that a later action doesn’t conflict with an earlier one. As a result, the project system can easily end up with multiple copies of a project’s data in memory at once. If the project system is not careful about managing these copies, they may be held on to longer than needed, or even leaked and retained permanently. We’ve worked aggressively to reduce the number of copies we hold on to at one time.

Large solution load improvements

With all this work, we have significantly optimized the experience of working with large .NET 5 and .NET Core solutions. As of 16.8, in many of our tests we have seen a 2.5x improvement in the size of solution we can open before running into resource issues. We have also seen a decrease of up to 25% in crashes reported due to resource exhaustion.

The improvements listed above are just the beginning of the changes we are making to improve the experience of working with large solutions in Visual Studio. Individual solution performance still may vary, depending on the size of the solution, type of projects, extensions loaded, etc., and there are still areas that we are looking at to improve. We encourage any users who are experiencing issues with slowness or crashes loading solutions to contact us at vssolutionload@microsoft.com so we can continue to improve the solution load experience for all solutions!