Overview
We’d like to gather feedback on implementing an equivalent of MSVC’s new Dynamic Debugging feature in LLVM. Dynamic Debugging lets developers step through unoptimized code from optimized binaries. Many of our users are interested in this feature as it largely removes the historical trade-off of runtime performance for improved debuggability.
The full unoptimized binary is compiled ahead of time along with the optimized version, and all of its globals reference the optimized binary (both data and code - so unoptimized functions call optimized ones). While debugging, when a breakpoint is requested the debugger patches the optimized function entry to jump to the unoptimized version. Because unoptimized functions call optimized versions, continuing execution returns execution to the optimized binary.
We’ve built a working proof of concept. This RFC is to gather opinions on our design for LLVM and our current implementation, which requires coordination from compiler, linker, and debugger.
Nested ELF design
We are currently only looking at ELF support. The broad idea is that the unoptimized program (“inner ELF”) is compiled and stored in a new .debug_llvm_dyndbg section in the optimized version (“outer ELF”). This differs from MSVC’s approach which generates two binaries. “Nesting” the ELFs allows build systems and other tools to continue to manage a single object file per translation unit.
High level nested ELF design:
-
In the outer (optimized) module, globals with internal linkage not in a COMDAT are “promoted” so they can be referenced by the inner module. We implement this by introducing aliases with external linkage and suffixes unique to the translation unit. The suffix is currently “.dyndbg.<hash of directory, file, and driver flags>” (we’re open to other spellings).
-
The inner (unoptimized) module contains unoptimized copies of the outer module’s functions. Their names are prefixed with “__dyndbg.” (again, open to other spellings).
-
Declarations corresponding to the originally external and now-promoted globals in the outer module are added to the inner module. All global references (function and data) in the inner module refer to the outer module.
-
After linking, the inner ELF is still essentially an ET_REL object. It’s the debugger’s job to extract, load, and apply relocations for the inner ELF.
This diagram illustrates the result of compiling a simple source example:
Compiler considerations
In our current implementation Clang clones the input module at after CodeGen (clang::emitBackendOutput) without global variable definitions, renames the functions as appropriate, and generates aliases as needed in the original (to-be-optimized) module. A seperate optimization pipeline (emitAssembly), set to O0, is run on the unoptimized module. The binary output of which is embedded into the to-be-optimized module (using embedBufferInModule) to be embedded in a .debug_llvm_dyndbg section.
Handling the cloned module here in the front-end simplifies some aspects (rather than teaching the LLVM backend code how to handle two modules), but has its own drawbacks. Most importantly, this approach makes it very difficult to share sections we otherwise might, such as debug strings or debug type units. I’m not certain how LTO would be supported this way either, though thus far LTO support has been a non-goal. Changes there may affect the implementation should not impact the design of it (the nested ELF approach).
Beyond the front end there are still some changes needed to LLVM itself. We can’t have any optimisations that change function interfaces at all, and any function cloning (e.g., function specialisation creating an local version of a global function) needs to be disabled as those functions won’t have corresponding unoptimized versions (and in the case of function specialisation it wouldn’t be safe to call the unoptimized global version after setting up a call to the optimized version, as the local version may have had its ABI messed with). Having external aliases for all the functions prevents the former but not the latter. GlobalOpt will replace some aliases with the aliasee, which is undesirable, and also needs to be explicitly disabled. Both of those behaviours could be controlled with a new function attribute, however, I am not proposing we re-open the “noipa” attribute discussion at this point. Given that external aliases provided a barrier to most interprocedural optimisations, and (as far as I’m aware) it’s just those two optimisations we need to control, I propose we go for something more targeted (that could be superceded by a noipa attribute in future).
On x86_64 we need functions to be at least 5 bytes large so our debugger can patch the entry to optimized functions to the unoptimized versions as needed. We propose adding another stringy function attribute to control this (tail-pad-to-size=<min byte size>).
We think it would be useful to add version information, for example in a .note.
Overall, Clang needs a small and localised change, and very few changes to LLVM itself are needed with the current design.
Linker considerations
The current implementation in ELF LLD basically performs a relocatable link of the unoptimized inner modules within the regular link of the optimized outer modules. The result of this inner relocatable link is placed in the .debug_llvm_dyndbg output section of the optimized outer link. The inner relocatable link uses the option --force-group-allocation to resolve groups and enables merging of the input sections. This helps to reduce the size of the output especially for inputs that use -ffunction-sections.
The key changes required in ELF LLD are to support the nested linking and to handle the symbol dependencies from the unoptimized inner modules to the optimized outer modules and the final executable output.
Debugger considerations
We want to ensure the design leaves room for implementation in LLDB, so we would greatly value feedback of LLDB/Apple folks on this.
From our debugger folks - broadly, a debugger that supports dynamic debugging must:
-
Read the relocations from the inner (unoptimised) ELF and apply them.
-
Load the
.textof the inner (unoptimised) ELF into the debuggee’s memory. -
Using
DW_TAG_inlined_subroutine, build a map from each inlined function to a set of parent functions that inline it.
When setting a file/line breakpoint for a non-inlined function the Debugger should:
-
Lookup the address in the inner ELF’s line information and set the breakpoint in the unoptimised code.
-
Lookup the address in the outer ELF’s line information and patch the optimised function with a detour to the unoptimised function.
When setting a file/line breakpoint for an inlined function the Debugger should:
-
Find the possible parent functions for the inlined function using the inliners map.
-
Repeat the process for non-inlined functions for every possible parent function.
Function breakpoints can use similar mechanisms as file/line breakpoints, where the address lookup is done using symbol information instead. Address breakpoints do not need any special handling.
Users may create breakpoints that require patching of the same function. Due to this, patches in optimised functions should be reference counted. Patches can be removed when the reference count drops to zero.
Function calls in the unoptimised code will call functions in the optimised code. The Debugger must force execution back to the unoptimised code when stepping into function calls from unoptimized code. The Debugger should:
-
Put a temporary breakpoint on the call target.
-
After stopping at the call target, map the PC to the corresponding function in the unoptimised code.
-
Set the PC to the start of the function in the unoptimised code.
The exact timing of the detour patching needs to considered. If the PC of any threads is currently in a function to be patched, it may not safe to apply. A fallback mechanism that uses temporary breakpoints and sets the PC in the Debugger when the breakpoint is hit can be used until the function is safe to patch. While using the fallback mechanism, the Debugger should also put a breakpoint in the optimised code. This is for the case where a thread may already be executing the function and will never end up in the unoptimised code.
There are some considerations around attaching and detaching. When detaching, the Debugger should remove any patches it has added to optimised code. Debuggers can decide whether to remove the unoptimised code from the debuggee’s memory when detaching. A Debugger could, for example, leave the code and some metadata behind so that if a user re-attaches later the initial loading and relocating doesn’t have to be done again.
Resource usage
The costs of compiling with dynamic debugging are not straightfowardly “unoptimized plus optimized build”. In terms of size, the inner ELF remains relocatable after the outer ELF is linked, so it contains many relocations. The outer ELF contains many more functions than it would under normal optimizations (we can’t delete the optimized definitions as they’re called from the inner ELF), and those come with extra debug info.
- Adding
-fdynamic-debuggingto a-O3 -gbuild results in an executable file size increase of+189.58%(and the increase for object files is more pronounced), geomean over CTMark projects.
Compile time can be fairly significantly impacted depending on the source. C++ parsing only happens once, and the codegen/emission time of the inner ELF is comparible to a normal unoptimzed build. However, the outer module can take significantly longer to compile than a normal optimized build due to the (sometimes vast) number of function definitions that are preserved that would otherwise be fully optimized away. Optimizations, ISel, and object emission all take longer as a result.
- Adding
-fdynamic-debuggingto a-O3 -gbuild results in an compile time increase of+14.81%, geomean over CTMark projects (measured in instructions:u sampled, as on compile-time-tracker). The range across the CTMark codebases is from less than +2% to nearly +40% increase, indicating this is highly dependant on the codebase.
Linking of output with dynamic debugging will have a significant memory usage overhead.
Summary
In summary, this feature would allow developers to see both the benefits of running optimized builds and debugging unoptimized builds in one ELF. It requires coordination from the compiler, linker, and debugger, so we are seeking feedback from contributors in all these areas.
Thanks for reading,
- Orlando (@OCHyams) and Andrew (@nga888)
