Preventing Exception Handling code from getting deleted

TL;DR how does exception handling really work? how is throw detected? is there any way to force exception handlers to be maintained without preventing optimizations?

Background:
I’m trying to implement implicit exception handling, i.e. translation of some signals into exceptions in Tyr (see example).

After analyzing the behavior for some days in Tyr and C++ now, I can see that optimizers seem to assume that the code cannot throw an exception and start throwing away exception handlers.
I attached the C++ example, but also the Tyr example and .ll file (with demangled names) to show that this is likely not a C++ problem.

min.cpp (706 Bytes)
test.1.ll.txt (390.2 KB)
mar.tyr.txt (528 Bytes)

The strange thing with the current semantics is that optimizations can result in throw from signal to bypass an arbitrary number of signal handlers including all. For the Tyr example, the naively expected behavior is observed with no optimizations and all exception handlers are discarded starting from -Og. In the C++ example, toggling the comment on the unreachable printf toggles the exception handler catching the exception.

I think I tried all flags that seem exception- or unwind-related but could not observe any changes here. Also, adding uwtable flags in the Tyr backend seemed not to change anything.

Note: generally speaking, I think it makes sense to rule that asynchronous exception handling has no defined semantics wrt. ordering of instruction execution or exception handlers that are really hit. On the other hand, I would not expect that the behavior changes on -Og or when adding or removing other function calls that do not themselves have any side effects wrt. exception handling.

Question 1:
There is a flag nounwind. The documentation states that

However, functions marked nounwind may still trap or generate asynchronous exceptions. Exception handling schemes that are recognized by LLVM to handle asynchronous exceptions, such as SEH, will still provide their implementation defined semantics.

Does that also mean that asynchronous exceptions do not work with ELF/DWARF?
Is there any means to mark a function as “mayunwind” to prevent it from getting a nounwind flag by whatever analysis/optimization?

My naive assumption was that nounwind + uwtable means that llvm must expect asynchronous exceptions to be thrown in the body of the respective function.

Question 1b:
Is this a bug in the DWARF exception handling related code emission? Initially, I thought it could be. But currently I consider it more of a lack of clarity of semantics of uwtable and invoke.

Question 2:
The more I digged into this, the more I realized that it isn’t really clear how throw is detected. I kind of assume that llvm somehow magically detects __cxa_throw or simply assumes that calls followed by unreachable must throw an exception. Can someone clarify this? Is this specified somewhere? Or is this simply that any external function without nounwind is expected to throw?
Maybe the answer could be used to derive a pattern to mark functions that, based on their type in Tyr, must be assumed to throw exceptions without preventing optimizations.

Question 3:
Optimizations seem to try to replace invoke with call and throw away the exception handlers. This makes a lot of sense in general. Is there any way to prevent this replacement other than not using optimizations?
My naive assumption would be that it should have semantics of “do not discard exception handlers unless the called function has an empty/stateless body and is inlined”.

To me, this seems a bit like a design issue of exception handling in llvm atm. because I do not see a way to keep the handler alive if there is no invoke instruction. It seems as if the SEH-related intrinsics need to be generalized to something that works with DWARF too.
Or copy llvm.donothing to llvm.try.start and llvm.try.end and optimize it only if there is no instruction between start and end to allow deletion of handlers in effective-empty try bodies.

Versions OS:
I tried Ubuntu 22.04 clang 14 and Ubuntu 25.04 clang 20; the behavior seems to be the same. With clang 20 I also tried a version with noexcept(false) but the .ll files seemed to be unaffected.

For further diagnoses, I added means to mark individual functions as optnone to Tyr and created a test using it.
Now, the strange thing is that the test follows the naive assumptions on what exception handling would mean if the function causing the signal is not optimized (fail). I consider this strange, since I would have assumed that not optimizing the handling function would also work (run). But it does not. What am I missing here?

I tried essentially the “llvm.donothing” approach by adding a nop function with optnone, but it did not work (test). The tests all pass in that form but cannot be reduced any further.
Help!? :slight_smile:

I’m not an expert on exception handling, but I’ll answer what I can:

The strange thing with the current semantics is that optimizations can
result in throw from signal to bypass an arbitrary number of signal
handlers including all. For the Tyr example, the naively expected
behavior is observed with no optimizations and all exception handlers
are discarded starting from -Og. In the C++ example, toggling the
comment on the unreachable printf toggles the exception handler
catching the exception.

LLVM doesn’t have any real support for asynchronous exceptions on
anything that’s not a function call (see
Add support for asynchronous "non-call" exceptions, eliminate invoke/call dichotomy · Issue #1641 · llvm/llvm-project · GitHub for more details).
Operations that could cause hardware traps are undefined behavior, in
the sense that the optimizer can (and will) go chomp-chomp on your code
if it knows it would trigger them.

The closest you get is the work done to support Windows SEH, the key
patch of which is here:
[Windows SEH]: HARDWARE EXCEPTION HANDLING (MSVC -EHa) - Part 1 · llvm/llvm-project@797ad70 · GitHub.

Does that also mean that asynchronous exceptions do not work with
ELF/DWARF?

All of the work i’ve seen done for asynchronous exceptions in LLVM have
been limited to supporting Windows SEH, so I would not expect anything
useful here (especially since POSIX signals don’t get mapped to unwind
semantics by any standard mechanism anyways).

The more I digged into this, the more I realized that it isn’t really
clear how throw is detected. I kind of assume that llvm somehow
magically detects __cxa_throw or simply assumes that calls followed by
unreachable must throw an exception. Can someone clarify this? Is this
specified somewhere? Or is this simply that any external function
without nounwind is expected to throw?

LLVM doesn’t have a native definition of “throw;” instead, it uses
“unwind.” Any call to an unknown external function is presumed to do
anything an unknown function could do except trigger undefined behavior.
And one of things an unknown function can do is trigger an unwind.

1 Like

This is basically accurate – by design, non-call exceptions are not supported. Making them work more reliably would probably require backporting MLIR regions to LLVM IR. The SEH start/stop intrinsics work OK, but they involve complex code to identify and color the blocks in the implied try-region, made more difficult by unreachable terminators and the general free, unstructured basic block CFG design of LLVM IR.

You might be able to get by with non-call exceptions emanating from call instructions by classifying your Tyr exception handling personality as “asynchronous”, which has a side effect of disabling the optimization that turns invokes to nounwind functions to calls (see llvm::canSimplifyInvokeNoUnwind). In turn, this will make inlining less of a semantics-preserving transformation, since inlining a function with no calls may orphan your exception handling landing pads, but it’s enough to allow you to do things like catch null pointer exceptions across library boundaries.

1 Like

Ok thanks. Both answers are very helpful and allow me to continue with an acceptable solution.

The intention of having signals converted to exceptions is to allow some meaningful bug report and shutdown logic to be implemented, especially if the error occurs in some work package management code like a shared thread pool. So, based on the experiments and explanation I’d currently assume that this will work as intended and testing it can be done with the avoidOptimization property similar to what I added to the Tyr conformance test suite.

Thanks a lot :slight_smile: