C# Debug vs. Release builds and debugging in Visual Studio – from novice to expert in one blog article


Repository for my PowerShell script to inspect the DebuggableAttribute of assemblies.


‘Out of the box’ the C# build configurations are Debug and Release.

I planned to write a write an introductory article but as I delved deeper into internals I started exploring actual behaviour with Roslyn vs. previous commentary / what the documentation states. So, while I do start with the basics, I hope there is something for more experienced C# developers too.

Disclaimer: Details will vary slightly for .NET languages other than C#.

A reminder of C# compilation

C# source code passes through 2 compilation steps to become CPU instructions that can be executed.

Diagram showing the 2 steps of compilation in the C# .NET ecosytem

As part of your continuous integration, step 1 would take place on the build server and then step 2 would happen later, whenever the application is being run. When working locally in Visual Studio, both steps, for your convenience, fire off the back of starting the application from the Debug menu.

Compilation step 1: The application is built by the C# compiler. Your code is turned into Common Intermediate Language (CIL), which can be executed in any environment that supports CIL (which from now on I will refer to as IL). Note that the assembly produced is not readable IL text but actually metadata and byte code as binary data (tools are available to view the IL in a text format).

Some code optimisation will be carried out (more on this further on).

Compilation  step 2:  The Just-in-time (JIT) compiler will convert the IL into instructions that the CPU on your machine can execute. This won’t all happen upfront though – in the normal mode of operation, methods are compiled at the time of calling, then cached for later use.

The JIT compiler is just one of a whole bunch of services that make up the Common Language Runtime (CLR), enabling it to execute .NET code.

The bulk of code optimisation will be carried out here (more on this further on).

What is compiler optimisation (in one sentence)?

It is the process of improving factors such as execution speed, size of the code, power usage and in the case of .NET, the time it takes to JIT compiler the code – all without altering the functionality, aka original intent of the programmer.

Why are we concerned with optimisation in this article?

I’ve stated that compilers at both steps will optimise your code. One of the key differences between the Debug and Release build configurations is whether the optimsations are disabled or not, so you do need to understand the implications of optimisation.

C# compiler optimisation

The C# compiler does not do a lot of optimisation. It relies ‘…upon the jitter to do the heavy lifting of optimizations when it generates the real machine code. ‘  (Eric Lippert). It will nonetheless still degrade the debugging experience.  You don’t need in-depth knowledge of C# optimisations to follow this article, but I’ll look at one to illustrate the effect on debugging:

The IL nop instruction (no operation)

The nop instruction has a number of uses in low level programming, such as including small, predictable delays or overwriting instructions you wish to remove. In IL, it is used to help breakpoints set in the your source code behave predictably when debugging.

If we look at the IL generated for a build with optimisations disabled:

nop instruction

This nop instruction directly maps to a curly bracket and allows us to add a breakpoint on it:

curly bracket associated with nop instruction

This would be optimised out of IL generated by the C# compiler if optimisations were enabled, with clear implications for your debugging experience.

For a more detailed discussion on C# compiler optimisations see Eric Lippert’s article: What does the optimize switch do?. There is also a good commentary of IL before and after being optimised here.

The JIT compiler optimisations

Despite having to perform its job swiftly at runtime, the JIT compiler performs a lot of optimisations. There’s not much info on its internals and it is a non-deterministic beast (like Forrest Gump’s box of chocolates) – varying in the native code it produces depending on a many factors. Even while your application is running it is profiling and possibly re-compiling code to improve performance. For a good set of examples of optimisations made by the JIT compiler checkout Sasha Goldshtein’s article.

I will just look at one example to illustrate the effect of optimisation on your debugging experience:

Method inlining

For the real-life optimisation made by the JIT compiler, I’d be showing you assembly instructions. This is just a mock-up in C# to give you the general idea:

Suppose I have:

The JIT compiler would likely perform an inline expansion on this, replacing the call to Add()   with the body of Add()  :

Clearly, trying to step through lines of code that have been moved is going to be difficult and you’ll also have a diminished stack trace.

The default build configurations

So now that you’ve refreshed your understanding of .NET compilation and the two ‘layers’ of optimisation, let’s take a look at the 2 build configurations available ‘out of the box’:

Visual Studio release and debug configurations

Pretty straightforward – Debug is fully optimised, the Release is not at all, which as you are now aware, is fundamental to how easy it is to debug your code. But this is just a superficial view of the possibilities with the debug and optimize arguments.

The optimize and debug arguments in depth

I’ve attempted to diagram these from the Roslyn and mscorlib code, including: CSharpCommandLineParser.cs, CodeGenerator.cs, ILEmitStyle.csdebuggerattributes.cs, Optimizer.cs and OptimizationLevel.cs. Blue parallelograms represent command line arguments and the greens are the resulting values in the codebase.

Diagram of optimize and debug command line arguments and their related settings in code

The OptimizationLevel enumeration

OptimizationLevel.Debug disables all optimizations by the C# compiler and disables JIT optimisations via DebuggableAttribute.DebuggingModes  , which with the help of ildasm, we can see is:

Manifest debuggable attribute

Given this is Little Endian Byte order, it reads as 0x107, which is 263, equating to: Default , DisableOptimizations , IgnoreSymbolStoreSequencePoints  and EnableEditAndContinue, (see debuggerattributes.cs.

OptimizationLevel.Release enables all optimizations by the C# compiler and enables JIT optimizations via DebuggableAttribute.DebuggingModes = ( 01 00 02 00 00 00 00 00 ) , which is just DebuggingModes.IgnoreSymbolStoreSequencePoints .

With this level of optimization, ‘sequence points may be optimized away. As a result it might not be possible to place or hit a breakpoint.’ Also, ‘user-defined locals might be optimized away. They might not be available while debugging.’ (OptimizationLevel.cs).

IL type explained

The type of IL is defined by the following enumeration from ILEmitStyle.cs.

As in the diagram above, the type of IL produced by the C# compiler is determined by the OptimizationLevel ; the debug argument won’t change this, with the exception of debug+ when the OptimizationLevel is Release i.e. in all but the case of debug+, optimize is the only argument that has any impact on optimisation – a departure from pre-Roslyn*.

* In Jeffry Richter’s CLR Via C# (2014), he states that optimize- with debug- results in the C# compiler not optimising IL and the JIT compiler optimising to native.

ILEmitStyle.Debug – no optimization of IL in addition to adding nop instructions in order to map sequence points to IL

ILEmitStyle.Release – do all optimizations

ILEmitStyle.DebugFriendlyRelease – only perform optimizations on the IL that do not degrade debugging. This is the interesting one. It comes off the back of a debug+ and only has an effect on optimized builds i.e. those with OptimizationLevel.Release. For optimize- builds debug+ behaves as debug.

The logic in (CodeGenerator.cs) describes it more clearly than I can:

The comment in the source file Optimizer.cs states that, they do not omit any user defined locals and do not carry values on the stack between statements. I’m glad I read this, as I was a bit disappointed with my own experiments in ildasm with debug+, as all I had been seeing was the retention of local variables and a lot more pushing and popping to and from the stack!

There is no intentional ‘deoptimizing’ such as adding nop instructions.

There’s no obvious direct way to chose this debug flag from within Visual Studio for C# projects? Is anyone making use of this in their production builds?

No difference between debug, debug:full and debug:pdbonly?

Correct – despite the current documentation and the help stating otherwise:

csc command line help

They all achieve the same result – a .pdb file is created. A peek at CSharpCommandLineParser.cs  can confirm this. And for good measure I did check I could attach and debug with WinDbg for both the pdbonly and full values.

They have no impact on code optimisation.

On the plus side, the documentation on Github is more accurate, although I’d say, still not very clear on the special behaviour of debug+.

I’m new.. what’s a .pdb? Put simply, a .pdb file stores debugging information about your DLL or EXE, which will help a debugger map the IL instructions to the original C# code.

What about debug+?

debug+ is its own thing and cannot be suffixed by either full or pdbonly. Some commentators suggest it is the same thing as debug:full, which is not exactly true as stated above – used with optimize- it is indeed the same, but when used with optimize+ it has its own unique behaviour, discussed above under DebugFriendlyRelease .

And debug- or no debug argument at all?

The defaults in CSharpCommandLineParser.cs are:

The values for debug- are:

So we can confidently say debug- and no debug argument result in the same  single effect – no .pdb file is created.

They have no impact on code optimisation.

Suppress JIT optimizations on module load

A checkbox under Options->Debugging->General; this is an option on the debugger in Visual Studio and is not going to affect the assemblies you build.

You should now appreciate that the JIT compiler does most of the significant optimisations and is the bigger hurdle to mapping back to the original source code for debugging. With this enabled, the debugger will request that DisableOptimizations  is ignored by the JIT compiler.

Until circa 2015 the default was enabled. I earlier cited CLR via C#, in that pre-Roslyn we could supply optimise- and debug- arguments to csc.exe and get unoptimised C# that was then optimised by the JIT compiler – so there would have been some use for suppressing the JIT optimisations in the Visual Studio debugger. However, now that anything being JIT optimised is already degrading the debugging experience via C# optimisations, Microsoft decided to default to disabled on the assumption that if you are running the Release build inside Visual Studio, you probably wish to see the behaviour of an optimised build at the expense of debugging.

Typically you only need to switch it on if you need to debug into DLLs from external sources such as NuGet packages.

If you’re trying to attach from Visual Studio to a Release build running in production (with a .pdb or other source for symbols) then an alternative way to instruct the JIT compiler not to optmiize is to add a .ini file with the same name as your executable along side it with the following:

Just My Code.. What?

By default, Options->Debugging→Enable Just My Code is enabled and the debugger considers optimised code to be non-user. The debugger is never even going to attempt non-user code with this enabled.

You could uncheck this option, and then theoretically you can hit breakpoints. But now you are debugging code optimised by both the C# and JIT compilers that barely matches your original source code, with a super-degraded experience – stepping through code will be unpredictable you will probably not be able to obtain the values in local variables.

You should only really be changing this option if working with DLLs from others where you have the .pdb file.

A closer look at DebuggableAttribute

Above, I mentioned using ildasm to examine the manifest of assemblies to examine DebuggableAttribute . I’ve also written a little PowerShell script to produce a friendlier result (available via download link at the start of the article).

Debug build:

Release build:

You can ignore IsJITTrackingEnabled, as it is has been ignored by the JIT compiler since .NET 2.0. The JIT compiler will always generate tracking information during debugging to match up IL with its machine code and track where local variables and function arguments are stored (Source).

IsJITOptimizerDisabled simply checks DebuggingFlags for DebuggingModes.DisableOptimizations. This is the one that turns on optimisation by the JIT compiler.

DebuggingModes.IgnoreSymbolStoreSequencePoints tells the debugger to work out the sequence points from the IL instead of loading the .pdb file, which would have performance implications. Sequence points are used to map locations in the IL code to locations in your C# source code. The JIT compiler will not compile any 2 sequence points into a single native instruction. With this flag, the JIT will not load the .pdb file. I’m not sure why this flag is being added to optimised builds by the C# compiler – any thoughts?

Key points

  • debug- (or no debug argument at all) now means: do not create a .pdb file.
  • debug, debug:full and debug:pdbonly all now causes a .pdb file to be output. debug+ will also do the same thing if used alongside optimize-.
  • debug+ is special when used alongside optimize+, creating IL that is easier to debug.
  • each ‘layer’ of optimisation (C# compiler, then JIT) further degrades your debugging experience. You will now get both ‘layers’ for optimize+ and neither of them for optimize-.
  • since .NET 2.0 the JIT compiler will always generate tracking information regardless of the attribute IsJITTrackingEnabled
  • whether building via VS or csc.exe, the DebuggableAttribute is now always present
  • the JIT can be told to ignore IsJITOptimizerDisabled during Visual Studio debugging via the general debugging option, Suppress JIT optimizations on module load. It can also be instructed to do so via a .ini file
  • optimised+ will create binaries that the debugger considers non-user code. You can disable the option Just My Code, but expect a severely degraded debugging experience,

You have a choice of:

  • Debug: debug|debug:full|debug:pdbonly optimize+
  • Release: debug-|no debug argument optimize+
  • DebugFriendlyRelease: debug+ optimize+

However, DebugFriendlyRelease is only possible by calling Roslyn csc.exe directly. I would be interested to hear from anyone that has been using this.