In Understanding .NET Debug vs Release we saw that the optimization in .NET happens with JIT, not with the Roslyn compiler. I mentioned that it’s possible to force the JIT compiler to treat a release build like a debug build. The secret to this is understanding how the .NET runtime knows when to optimize the code.
When an assembly is compiled, Roslyn injects a few assembly attributes. Some of these attributes contain metadata such as the target framework version, the compiler version, and compatibility settings. The configuration name (Debug or Release) is persisted using
AssemblyConfigurationAttribute. Roslyn also injects one attribute which configures the JIT settings:
For a release build, it has a minimal configuration:
When IL is generated for assemblies, Roslyn uses a set of rules to encode implied sequence points (I covered sequence points in What Every Developer Should Know About PDBs). This allows the JIT compiler to map sequence points to generated native code without needing to load a PDB. When debugging, a PDB (with explicit sequence points) will be used if it is available.
Earlier versions of .NET didn’t include this attribute. They always tried to load the PDB to retrieve the sequence points. This is why so many early articles on .NET discussed the performance issues with including PDBs. Thankfully, this hasn’t been an issue for a very long time!
A debug build is configured differently:
1[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue | DebuggableAttribute.DebuggingModes.DisableOptimizations)]
This configuration disables the JIT optimizations and enables module-level support for edit-and-continue (allowing a debugger to dynamically swap code and re-JIT the method).
DebuggingModes.Default enables JIT tracking (mapping native code to IL); because this feature is always permanently enabled, this value has no practical effect after .NET 2.0.
When an assembly is executed, this attribute is used by the loaded to define the default JIT experience. As a result, assemblies built for debug will not be optimized by JIT, leaving extra instructions and breakpoints available. This provides the expected debugging experience. Without those attributes, the JIT process tries to optimize the code.
In short, the difference in the performance and experience is this attribute. That’s why the IL doesn’t have to change between the builds (and why so much is deferred to JIT). But how do we change the JIT behavior if we want to debug a release build? It turns out that many of the runtime features are configurable using environment variables to override the default values (or assembly settings). These values start with
DOTNET_ (or, in older .NET versions,
COMPLUS_). A full set of values can be seen
in this header file, with some additional JIT-specific details covered
in the JIT header file.
To override the standard Release behaviors and use Debug behaviors, we just need to configure one or more environment variables:
||Must be set to
Setting these values causes the assembly to run with the specified configuration. As a result, a release build’s JIT process will create the same code as a debug build!
Let’s not and say we did?
Consider these settings as a way to enable yourself to connect and debug tough-to-reproduce issues. In general, you won’t want to use these settings. The act of connecting a debugger changes a number of things about the environment, including allowing the runtime to limit how threads are executing in the background. You wouldn’t use these settings without a debugger since they reduce the performance of the JIT compiler. Sometimes there are extreme cases where this may be required, and it’s great to know that .NET builds it into the runtime.
Generally speaking, I recommend there are two approaches I recommend trying first:
Using the built-in semantic logging functionality. This is the single most powerful debugging tool at your disposal, and it can give you rich, real-time information in a structured format. Properly implemented, it can automatically integrate with other logging systems. It also natively supports Event Tracing for Windows (ETW) and Linux Trace Toolkit: Next Generation (LTTNG). These technologies offer high-performance logging and dynamic tracing. Good logging is essential to being able to quickly diagnose and debug issues.
Use the dotnet dump global tool to create memory dumps. These files contain a snapshot of the memory state for an application. If the application has PDBs, then it becomes possible to explore the state (and see the variables in any running methods). The memory dump can be explored using Visual Studio (Windows), LLDB (Linux), or
dotnet analyze(cross-platform command line). Combined with insights from logging, this can allow you to dive deep into the process to determine where a set of issues are coming from. In Azure, Application Insights offers the Snapshot Debugger, which can capture minidumps for exceptions.
In most cases, identify issues and their cause using one or both of these approaches. For the rare case where you need to be able to step into the code for deeper live debugging against a release build, you can use the environment variables to configure JIT appropriately. Paired with good symbols, you should be able to gather any needed insights from your code.