Ken Muse

Understanding .NET Debug vs Release


This is a post in the series Mastering PDBs and Debugging. The posts in this series include:

The last few posts have directly explored .NET symbols and PDBs. There’s one other aspect of .NET That always confuses development teams – understanding Debug vs Release builds. If you’re not sure what the differences are, you’re not alone. Like PDBs, there are lots of myths and misconceptions. I’ve seen teams deploy debug builds to production thinking that was the only option to allow profiling and troubleshooting. I’ve also seen teams preserving both builds in case they need to debug. In truth, both of those roles are handled by the PDBs. So why do we have the two options?

The myths

There are a few myths I want to directly address. First, many developers believe that a release build optimizes the compiled code. This is only partially true. At the time .NET originated, most native code used optimizing compilers. This improved the size and performance of the binary. With .NET (and Java), the authors took a different approach. The actual optimization occurs as part of the just-in-time compilation, when the intermediate language (IL) byte code is converted to machine code. Because of that, it’s important for the .NET compiler (Roslyn) to minimize how much optimizing it performs. As a result, it’s not uncommon for many compiled methods to be identical when comparing the outputs of release and a debug builds.

Despite its name, the /optimize flag does little more than turn off support for debugging. We don’t perform optimizing transformations on the code except very locally during emit, for example to avoid a redundant store then load. We don’t generate code for unreachable statements.
Neal Gafter, Ph.D (former Microsoft Principal Software Engineer on the Roslyn team)

The second big myth is that a debug build is required for debugging. As we’ve seen over the last few posts, that’s the role of the PDB symbol file. It’s also why it’s so important that you always generate this file (or embed it). When actively debugging code, the JIT compiler generates different machine code to facilitate this process. When not debugging (and capturing a mini-dump), the PDB is used to map the source code, MSIL, and machine code and enable stepping into the process.

Under the covers

Why even have a debug build if it’s not required for debugging? The reason is because it sets up the debugging experience by default. It optimizes the JIT process for debugging and it may generate slightly different MSIL to make it easier to step through the code. Naturally, this is preferable when developing new code in an IDE. These changes are not ideal for production code and can create performance differences. This is why you are encouraged to always use a release build for production purposes. Don’t worry this isn’t completely clear. That’s why this post isn’t finished yet. 😄

To get a better understanding of what is happening, let’s examine this sample C# code:

1Console.WriteLine("Hello, World!");
2if (false)
3{
4    Console.WriteLine("Not called");
5}
6int a = 0;
7a++;

This code simply writes “Hello, World!” to the console. It has a section of code that is unreachable (line 4). It also has code that creates and then ignores a local variable, a (lines 6-7). How do you think the C# compiler handles this code for a build? Most developers assume that lines 2-7 are removed for a release build and that they all remain for a debug build.

The optimizer for C# only allows for a limited set of operations when compiling. The majority of the optimization is left to the JIT process. Some of the rules (simplified) include:

  1. Local variables can never be optimized out at compile time. They must remain to support debugging.
  2. Unreachable code is always eliminated.
  3. Branch operations with a constant condition can be removed or inlined.
  4. When creating a debug build, reachable code must support breakpoints.

Following these rules, a debug build generates the IL equivalent of this code:

1        Console.WriteLine("Hello, World!");
2        bool flag = false;
3        int a = 0;
4        a++;

The if (false) branch has a constant condition, so it could be removed; however, a debug build must allow setting breakpoints on all reachable code. The flag variable replaces the line if (false), allowing you to set a breakpoint on that line. Because the inner code block (lines 3 - 5 of the original code) is unreachable, it is eliminated. The remaining lines closely match the original code.

What happens with a release build is similar:

1Console.WriteLine("Hello, World!");
2int a = 0;
3a++;

In this case, the branch with a constant condition is removed and no longer available for setting a breakpoint. As a result, it doesn’t need to generate any code. Similar to the debug build, the unreachable code is eliminated as well. Notice that the increment (a++) step is not optimized out. Since Roslyn is not allowed to eliminate local variables, the initialized variable a must remain.

Because the value of a++ is never used in the execution path, the code can be eliminated. The JIT process will actually eliminate the unused execution paths and local variables, simplifying the code that is actually running to the native code equivalent of:

1Console.WriteLine("Hello World!")

Consequently, even when code exists in the IL, it may not support a breakpoint in a release build. For production code, the release build (and JIT) eliminates code that only exists to support breakpoints, resulting in a faster JIT and smaller, faster code.

This is why the IDE prefers the debug build – the ability to set arbitrary breakpoints on any reachable code. With a release build, code that is unreachable or which has no side effects may be eliminated by either Roslyn or the JIT process.

The danger of eliminating PDBs

In What Developers Should Know About PDBs, I mentioned that doing a release build without generating PDBs changes the code. For this example, the resulting release code without a PDB looks like this:

1Console.WriteLine("Hello, World!");
2int a = 0;

Notice that we’ve only lost the call to a++. Roslyn cannot optimize away the unused local variable, so it remains behind. The JIT process then simplifies this to:

1Console.WriteLine("Hello World!")

Notice that this is the same code, and the JIT compilation still needs to optimize the generated native code. The few bytes of IL we’ve avoided won’t save any significant time during the JIT process. That’s also typically a one-time expense that happens asynchronously in the background, so its rarely even observable. Because the IL is different, rebuilding the code to create a PDB won’t help. The binary and PDB will reference different blocks of code, so they will be incompatible. You can see why not generating PDBs at build has such a negative impact. The executing code is the identical, but you’ve lost any ability to effectively debug it!

Conditional methods

As one final example, consider this private method:

1[Conditional("DEBUG")]
2private static void DoSomething()
3{
4  // Lots of code here
5}

In a debug build, this method and any calls to it are unchanged. You may be surprised to know that the compiler still emits the method in a release build; JIT will convert this to optimized machine code. Roslyn removes any direct calls to that method from the release build, preventing direct invocation. This keeps the code from being called at runtime. The code still exists, however, so it is still invocable using the reflection APIs.

This is why I said the optimization myth is partially true. Roslyn can perform some optimizations, but most are deferred to JIT. If you want to learn more, I’d suggest starting with Eric Lippert’s article What does the optimize switch do?

This decision is also part of the reason why you can use a release build for debugging. As you’ve seen, most of the code isn’t actually eliminated until the JIT process. With the PDB, we can restore access to line-level details and the mapping of source to the IL. We just need to convince the JIT process to not perform its optimizations.

We’ll cover how that works in the next post.