37 ms·
It's worth mentioning this scalability issue doesn't apply to SJLJ exceptions. I wrote a program that throws an exception and catches it to warm up, and then t
by jart 2y ago
It's worth mentioning this scalability issue doesn't apply to SJLJ exceptions.
I wrote a program that throws an exception and catches it to warm up, and then throws a second exception.
void first() {
throw "";
}
void second() {
throw 2;
}
int main(int argc, char *argv[]) {
try {
first();
} catch (...) {
}
second();
}
The second() function with LLVM libcxx using normal DWARF exceptions calls pthread_rwlock_rdlock SIX TIMES and calls pthread_rwlock_wrlock one time. Overall, this program triggers ~4000 function calls according to --ftrace (see https://justine.lol/ftrace/ https://justine.lol/ftrace/)
On the other hand, if I use SJLJ, then this program triggers 269 function calls. It never locks anything. Although, like the first program, it does call pthread_once three times. That's doesn't cost anything though. It's the rwlocks you need to worry about.
For an example of a modern C++ toolchain that uses SJLJ exceptions, see https://github.com/jart/cosmopolitan/releases/tag/3.7.1 https://github.com/jart/cosmopolitan/releases/tag/3.7.1
Please note that SJLJ exceptions carries a tradeoff. While SJLJ will make throwing exceptions much faster, you'll incur a slight performance degradation on normal code that isn't throwing exceptions. Using GCC in SJLJ mode will cause most of your C++ functions to have extra code inserted into the prologue and epilogue which calls _Unwind_SjLj_Register and _Unwind_SjLj_Unregister. Modern DWARF exception handling makes the intentional tradeoff of making exception throwing extremely slow, so that zero overhead is added for normal non-exceptional code. Both methods (SJLJ and DWARF) will bloat your binary size. With SJLJ it's just a few instructions inserted into prologues and epilogues. But with DWARF you get a lot of mandatory .eh_frame junk that's generated by everything, and it's centralized into a particular section of your binary that's only touched if exceptions are actually thrown.
- bryancoxwell 2y agoCan you expand on the concept of throwing/catching to warm up? I’ve never heard of that before (though I’m also not remotely familiar with C++).
- achierius 2y agoGenerally warming up caches (l1i, l1d, l2, ...) and other hardware state (e.g. out-of-order engine) so that you get a better picture of how your program will run in the steady state, rather than how it will perform on the first iteration with 'cold caches'. The justification for this is that in most (but not all) software applications, you should expect a reasonable amount of spatial/temporal locality and thus for your caches to be useful. So the 'average' performance of your program would be best captured by how the program runs when 'warm'. Warm -> populated with the code/data your program uses. Cold -> empty, or filled with stuff your program doesn't use. I do think that the case of exception handling is one where I would question the validity of this approach: if you catch exceptions rarely, it's quite reasonable to expect that the relevant lines would have been conflict'ed out of your l1i at some point in the interim, and therefore that the l1i (or even l2!) fetch cost is reasonable to include in the benchmark. However, if you expect your exceptions to fire 'relatively often' then it again becomes reasonable to warm up the caches before measuring, so it really comes down to the characteristics of your workload.
- jart 2y agoI'm just teasing out the one-time costs. Keep in mind, I'm using the above example to trace the libc/libc++/libcxxabi/libunwind runtimes. The first time you throw, it does a bunch of extra stuff, like getenv() and additional locking. I think the first throw needs about thirteen synchronization barriers total.
- bestouff 2y agoStill that's awful. I way prefer the Rust way of "simulated exceptions" via Result return types (currently only available in nightly though).
- dataflow 2y agostd::expected?
- jeffreygoesto 2y agoJust be aware that this is a variant and can't be returned via a register, but needs the stack. In (very) hot paths it can be worth switching back to single return values.
- kldx 2y agoAren't we paying for Result types every time it's used? A result of a 64 bit value wouldn't fit into $eax for example (I understand there are exceptions to this rule through invalid bit pattern optimizations)
- kldx 2y agoApologies, I mean $rax
- menaerus 2y agoScalability issue in OP exceptions is because their code is throwing a lot of exceptions from multiple threads at the same time and therefore causing resource contention in libgcc unwind implementation. Since you have only shown a simple example of throwing exceptions I'm not sure I understood your point how SJLJ exceptions solve the scalability problem? Truth to be told I'm not very familiar with SJLJ.
- jart 2y agoThe SJLJ runtime doesn't lock a global mutex seven times each time you throw an exception. I'm not kidding. If you use DWARF exceptions then, each time you use the throw keyword, libunwind::DwarfFDECache<libunwind::LocalAddressSpace>::_lock needs to be acquired six time in read mode, and once in write mode. Yes it's a GIL. I traced the address of the lock objects myself. That effectively makes throwing an exception a serialized operation. Only one exception can be thrown at a time, per process. Not only will this limit your throughput to one core, it'll also saturate all the other cores on your system with pointless busy work too, if your pthread_rwlock_t implementation isn't very good, e.g. glibc, musl. See also https://justine.lol/mutex/ https://justine.lol/mutex/ The example I gave is just the tip of the iceberg. I'm using it to analyze the runtimes beneath.
- fweimer 2y agoSJLJ exceptions do not need to perform ancillary data lookup using program counter values. Instead, there is code on the non-error path that maintains a linked list of exception landing sites when try blocks are entered/exited. Run-time type information is still needed for catch clause matching, but that data can be accessed easily via the vtable pointer and information next to the vtable. None of this needs to access mutable global data structures. DWARF exception handling needs DWARF unwind information, which is mapped in memory along with the code. But the stack only has program counters, not the addresses of the DWARF data. Therefore, the first step is to locate the DWARF data. The semi-portable interface (supported on GNU/Linux, various BSD, and likely others) for that is dl_iterate_phdr, and that invokes a callback under a global lock. The global lock is required because the list of objects can change in response to dlopen and dlclose calls. With the new way, the GCC unwinder directly asks the glibc dynamic linker for the ELF object corresponding to a program counter value and its DWARF unwind information address. This way, the synchronization with dlopen/dlclose is an internal dynamic linker implementation detail.