9 ms·
The Cost of Exception Handling
- dataflow 4y agoIs this refuting a claim that is actually made? Do people claim exceptions never have overhead on the happy path in C++? As an analogy, forget exceptions for a moment—just imagine plain function calls in C++ vs. Python. In Python, function calls always have overhead. In C++, though, we say function calls don't add overhead, because function calls can be inlined. But when we make that claim, we obviously don't mean function calls never have overhead in C++—obviously, the statement can't apply if you can't inline the call. Rather, the meaning of the claim is that function calls don't inherently introduce overhead—i.e. if they do, it's because of some other fact (like inability to inline), not the mere existence of the function call itself. The case seems similar to me here. When people say exceptions don't slow down the happy path, they don't mean this is impossible. They just mean that if this does occur, it's due to additional inherent obstacles beyond the mere enablement of exceptions (e.g., opaque function calls).
- deleted 4y ago[deleted]
- pjc50 4y ago"Zero-cost exceptions aren’t actually zero cost" : https://devblogs.microsoft.com/oldnewthing/20220228-00/?p=106296 https://devblogs.microsoft.com/oldnewthing/20220228-00/?p=10...
- sanxiyn 4y agoA great post. Note that the post is specific to C++ and does not apply to, say, Java. Java is effectively always paying the cost of exception handling, so you might as well use it.
- moonchild 4y agoA better way to put it: java doesn't have destructors—mostly because it has proper gc, so it doesn't have to spend time piddling around and freeing objects—so you never have to pay most of the costs that c++ pays.
- pencilguin 4y agoOr, rather, pays literally all the time, exceptions or no.
- lokedhs 4y agoIt doesn't really. Freeing heap objects in C++ is an expensive operation. You may want to look into how that is implemented in the common allocators. In Java, an allocation is literally a single instruction (adding a value to the heap pointer), and freeing is zero cost (since you just drop the pointer after you're done with it). By contrast, in C++ both allocation and freeing of heap memory are costly operations (which is there is so much emphasis on stack allocation). As it turns out, the time taken by the garbage collector in Java (which doesn't exist in C++) is still going to be less than what is spent on managing the same number of heap objects in C++. The only reason why C++ can be faster than the Java memory management is thanks to all the stack allocation.
- hinkley 4y agoBy Java 5 the average cost of an allocation in Java was already lower than heap allocation in C++, but greater than the cost of stack allocation. With escape analysis in Java 8 that got even better. The biggest problem Java had was that 90’s era thinking on GC algorithms (most GC languages in the 90’s were using 80’s era GC algorithms) ran out of steam sometime around 2005, when memory availability created situations where GC pause times were impossible to hide. Cliff Click created a JVM that actually ran as a VM, in order to create a cheaper mark phase via page faulting to implement mark on write. Now we have other options.
- hayley-patton 4y ago
- saurik 4y agoSo, I went to replicate the bottom case, and particularly want to do it more "fairly" by actually isolating the resulting code rather than staring at a ton of complex intermediate assembly :/. I'm thereby building a real binary--after carefully separating the compilation stages using -c and avoiding any linker optimization--instead of -S and then using objdump -d and then--because this is the whole point of talking about the happy path--I'm only providing the code of the actual functions and not any subsequent exception landing pads. Without exceptions: 0000000000401110 <main>: 401110: 50 push %rax 401111: e8 2a 00 00 00 callq 401140 <construct> 401116: e8 25 00 00 00 callq 401140 <construct> 40111b: e8 20 00 00 00 callq 401140 <construct> 401120: e8 2b 00 00 00 callq 401150 <destruct> 401125: e8 26 00 00 00 callq 401150 <destruct> 40112a: e8 21 00 00 00 callq 401150 <destruct> 40112f: b8 08 00 00 00 mov $0x8,%eax 401134: 59 pop %rcx 401135: c3 retq With exceptions: 0000000000401160 <main>: 401160: 50 push %rax 401161: 48 89 e7 mov %rsp,%rdi 401164: e8 37 00 00 00 callq 4011a0 <A<5u, void>::A()> 401169: e8 a2 00 00 00 callq 401210 <destruct> 40116e: e8 9d 00 00 00 callq 401210 <destruct> 401173: e8 98 00 00 00 callq 401210 <destruct> 401178: b8 08 00 00 00 mov $0x8,%eax 40117d: 59 pop %rcx 40117e: c3 retq 00000000004011a0 <A<5u, void>::A()>: 4011a0: 53 push %rbx 4011a1: e8 5a 00 00 00 callq 401200 <construct> 4011a6: e8 55 00 00 00 callq 401200 <construct> 4011ab: e8 50 00 00 00 callq 401200 <construct> 4011b0: 5b pop %rbx 4011b1: c3 retq Now, first off, I hope people can see that this is a much more fair comparison. I am not sure why the author of this article wanted to use -S, but it seems more than a bit disingenuous to be complaining about how "tidy" the happy path is after pasting a million assembler directives at us as if those actually have a runtime performance cost instead of actually looking at the resulting execution paths through the assembly that results. (And I want to be very clear here: this isn't because I somehow got a different result. If you carefully read through the assembly presented in the article and mentally skip all the assembler directives, you will see they got something similar, with a call to the constructor followed by three calls to destruct immediately one after the other. That said, I am not sure if the author actually understood what they were looking at, as they didn't provide the code for __ZN1AILj5EvEC2Ev, which is clearly part of the happy path.) But... yeah: it added the cost of a single extra call indirection for some reason. I'm not entirely sure why, but this frankly doesn't feel like a ridiculous cost. I will also note that it actually generates identical code to the case without exceptions if you merely add a single "inline" keyword to the code on the constructor for A (note: I mean the one on line 9 that is ostensibly empty, not the one on line 16). That said, you also probably wouldn't actually WRITE such a no-op constructor in the first place, and it turns out deleting it also fixes the resulting code to be exactly the same with and without exceptions, so I'm not sure of the applicability here... this just feels like such a corner case :/. (The actual reasonable complaint to make about "zero cost" exceptions--which this article didn't make, because it seems that it didn't occur to the author to do an actual timing analysis, which is where I was hoping this article would go--is that the exception tables tend to get mixed in with the rest of the normal functions, which massively pads out the code space, which will lead to many more TLB misses and page fetches while executing the happy path. There should be a way to move these landing pads all to the end of the executable to be loaded into memory only if absolutely necessary; but, if that's already possible, I personally don't know how to do that :(.)
- userbinator 4y agoEven in the first, "most optimised" case, the compiler is still generating more instructions than necessary. It should only need to generate one mov and one ret.
- rustybolt 4y ago> Let us now assume that we hide the function definitions: extern void construct(), destruct(); > The generated code in that case becomes noticeably longer This was hard for me to follow, but I think that when the function definitions are removed, the compiler no longer knows that the constructor can't throw an exception, so now it has to add exception handling code?
- hyperman1 4y agoExceptions only make sense if there are failures to handle. If you don't use exceptions for failure, something else like an 'if not ok goto handle' needs to be in there. This is what C does explicitly, and rust implicitly if you look trough Result. So a better comparison would be: let construct() return a bool, an do manual error handling if it returns false. I'm pretty sure, the compiler will generate much worse code in this case.
- pjc50 4y agoTo a great extent C++ exceptions exist and are the way they are because you can't return an error from a constructor.
- masklinn 4y agoOn the other hand, constructors don’t allow returning an error because C++ has exceptions.
- deleted 4y ago[deleted]
- pjc50 4y agoI mean .. yes, but easy multiple-value-return unpacking is also a fairly new feature. Back in the 90s you had one (1) lvalue. Making the "return (thing, error)" pattern miserable to use.
- masklinn 4y agoI was going to say that such an alternative history C++ could have had MRVs earlier, or even sum types... but apparently exceptions were added a decade after constructors (if we draw that back to the original "C with classes"). So yeah, you're right that constructors led to exceptions, unless the designers of C++ had been willing to go back to the drawing board and entirely redo constructors.
- chrisseaton 4y ago
- mrjin 4y agoTook a glance, it seemed the author took exception handling as simple as try {} catch{} and thus never discuss OS' role in exception handling. But one of the most expensive part in user mode exception handling is context switches between user mode and kernel mode.
- pjc50 4y agoIs that incurred for user-initiated exceptions (not traps or Windows "SEH")? In which implementations?
- ishitatsuyuki 4y agoIt is… not? It simply invokes the unwinder / exception handler, like _Unwind_RaiseException. For normal exceptions, no signals are involved. You can get an idea of what is executed at http://stffrdhrn.github.io/software/toolchain/openrisc/2020/12/13/cxx-exception-unwinding.html http://stffrdhrn.github.io/software/toolchain/openrisc/2020/....
- tsimionescu 4y agoAre you referring to interrupts by any chance? The OS has nothing whatsoever to do with C++ exceptions in user-mode code.
- bregma 4y agoUsermode exception handling performs no system calls. Perhaps you're thinking of user handling of system exceptions on Windows, which are pretty much unrelated to C++ exceptions?
- TheAceOfHearts 4y agoAn underrated topic which I haven't seen discussed much is what you should actually do in the real world when you encounter certain exceptions. Too many examples just kinda gloss over that by saying "oh you should handle any errors or exceptions here". If my memory allocation fails or if some file-related operation fails, what are the chances that I can actually do anything about it? Sometimes the best thing to do is just let it crash and log the event. It would be great to see a book about common strategies for handling exceptions and other forms of failure in real world applications. I'd imagine that certain strategies for handling exceptions would also require you to write your application in such a way as to make it possible to recover from failures as well.
- hyperman1 4y agoIf something goes wrong, there is not that much you can do. AFAIK the options are 1) retry 2) fall back to something else 3) give up and complain to someone. The options are simple, even if none of them are particularly nice. There are a lot of variations on these, of course: log it, and to where? keep unfinished work around somewhere or crash and burn? what if even logging the failure fails?
- fjdiccf 4y agoA guy I once worked with had a really good approach to exceptions: don't. Basically if your code should throw, instead of throwing. Do something about it. Rollback the filesystem or transaction so things can retry or the process can exit. Ideally, do not rely on someone reading a log message. Instead, do what that person reading the log would need to do. If that's an escalation to support staff or the CEO. Send the email. If the developer reading the log would just press a retry button, press the button for them. Basically, giving up was just not an option, it worked pretty well tbh, sure we had logs and they were crucial for debugging failures but weekend callouts were not a thing. We never had to babysit processes. Never had to worry about filesystem corruption if you want to kill -9 a process mid run.
- amelius 4y ago
- amelius 4y agoThe author has quite an interesting homepage: http://c3d.github.io/ http://c3d.github.io/ He developed several research programming languages, and he wrote a book on the possible unification of QM and relativity.
- elteto 4y agoAside: this made me chuckle: extern void construct(), destruct(); Never occurred to me.
- pencilguin 4y agovoid construe(), destroy();
- mxmlnkn 4y agoIt is interesting to read how the compiler behaves when using instructions but the number of generated instructions is only one metric. As always, there is nothing better than measuring the runtime of your actual implementation. I'm writing this because I had a tight-running loop that returned a bool and in 1 out of 100k or so cases, it would return false. It turns out that instead of checking that bool in each loop iteration, it was actually significantly faster to check nothing and throw an exception in that very rare case. Something like this: try { while ( true ) { foo.read(); } } catch ( const std::exception& ) { // foo.read throws on eof } instead of this: while ( !foo.eof() ) { foo.read(); } This might not be the most C++-like code but it saved me like 50% of runtime in one case. Afaik as long as exceptions are not thrown, there is zero overhead, which would explain this. And, the call to eof might have been too complex and long-running, maybe I could have optimized that one instead. But there are abstractions that might hinder such optimizations.
- throwaway9870 4y agoYou are making two functions calls in the second loop, vs one in the first. If the result of read() could be checked for eof, then you would remove one call. Also, since the branch is so easily predicted, I would be surprised if there is much of a performance hit once the second call is removed. If you see a 50% speed-up in the above code, I question what read is doing and the implementation of eof(). I would expect read() to dominate the if statement unless eof is extremely expensive. I have written dozens of read()/eof() functions over the years and read() always dominates because the read involves copying memory and/or making OS calls, etc., while eof() is a simple comparison. So if read() dominates runtime, example 1 and 2 above should not be anywhere close to 50% different.
- mxmlnkn 4y agoThat particular code came from a bit reader class. So, if you are reading only a few bits per call, then the read call becomes very cheap. It might be as "simple" as this pseudocode if ( offset + requestedBitCount < 64 ) { offset += requestedBitCount; return ( bitBuffer >> offset ) & nLowestBitsSet( requestedBitCount ); } And yes, I think the eof call might have been too complex especially in contrast to the short read call. It probably should only check a flag that might be set by the read call automatically.
- rr808 4y agoI think the move to FP cements my view that exception handling is more trouble than its worth. Golang is good too that it doesn't use exceptions except in the extreme. Java with checked exceptions was kinda fashionable for a while but is now unloved. Down with exceptions!
- pencilguin 4y agoConfirmation bias is always common, but not a thing to boast of.
- sanxiyn 4y agoI am not sure what FP you are talking about. Exception is core feature of ML and pervasive in OCaml standard library, for example.
- hinkley 4y agoI haven’t been watching the benchmarks lately but for quite some time OCaml was notable for being nearly as fast as C. No other function friendly language was even close.
- yCombLinks 4y agoI don't see how wrapping everything in Try is significantly different than wrapping things in catch blocks at the end of the day. I see more people complaining about checked exceptions than I actually see them being used anyways.
- gpderetta 4y agoAt the end of the day it always boils down to continuations.
- pjfin123 4y agoMy strategy is increasingly to not worry about optimization and instead write good semanticly correct code that the compiler can optimize.