Add zero-overhead variants, CPPTRACE_CATCH_ALT, and unprefixed aliases

This commit is contained in:
Jeremy Rifkin 2024-08-20 20:53:29 -05:00
parent 73ca2b5b49
commit adee091491
No known key found for this signature in database
GPG Key ID: 19AA8270105E8EB4
7 changed files with 303 additions and 23 deletions

View File

@ -317,6 +317,10 @@ if(NOT CPPTRACE_STD_FORMAT)
target_compile_definitions(${target_name} PUBLIC CPPTRACE_NO_STD_FORMAT)
endif()
if(CPPTRACE_UNPREFIXED_TRY_CATCH)
target_compile_definitions(${target_name} PUBLIC CPPTRACE_UNPREFIXED_TRY_CATCH)
endif()
if(CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang")
SET(CMAKE_C_ARCHIVE_FINISH "<CMAKE_RANLIB> -no_warning_for_no_symbols -c <TARGET>")
SET(CMAKE_CXX_ARCHIVE_FINISH "<CMAKE_RANLIB> -no_warning_for_no_symbols -c <TARGET>")

118
README.md
View File

@ -29,6 +29,7 @@ Cpptrace also has a C API, docs [here](docs/c-api.md).
- [Utilities](#utilities)
- [Configuration](#configuration)
- [Traces From All Exceptions](#traces-from-all-exceptions)
- [Removing the `CPPTRACE_` prefix](#removing-the-cpptrace_-prefix)
- [How it works](#how-it-works)
- [Performance](#performance)
- [Traced Exception Objects](#traced-exception-objects)
@ -371,14 +372,14 @@ namespace cpptrace {
### Traces From All Exceptions
Cpptrace provides `CPPTRACE_TRY` and `CPPTRACE_CATCH` macros that allow a stack trace to be collected from the current
thrown exception object, with no overhead in the non-throwing path:
thrown exception object, with minimal or no overhead in the non-throwing path:
```cpp
CPPTRACE_TRY {
foo();
} CPPTRACE_CATCH(const std::exception& e) {
std::cout<<"Exception: "<<e.what()<<std::endl;
std::cout<<cpptrace::from_current_exception().to_string(true)<<std::endl;
std::cerr<<"Exception: "<<e.what()<<std::endl;
cpptrace::from_current_exception().print();
}
```
@ -396,36 +397,121 @@ API functions:
- `cpptrace::from_current_exception`: Returns a resolved `const stacktrace&` from the current exception. Invalidates
references to traces returned by `cpptrace::raw_trace_from_current_exception`.
There is a performance tradeoff with this functionality: Either the try-block can be zero overhead in the
non-throwing path with potential expense in the throwing path, or the try-block can have very minimal overhead
due to bookkeeping with guarantees about the expense of the throwing path. More details on this tradeoff
[below](#performance). Cpptrace provides macros for both sides of this tradeoff:
- `CPPTRACE_TRY`/`CPPTRACE_CATCH`: Minimal overhead in the non-throwing path (one `mov` on x86, and this may be
optimized out if the compiler is able)
- `CPPTRACE_TRYZ`/`CPPTRACE_CATCHZ`: Zero overhead in the throwing path, potential extra cost in the throwing path
Note: It's important to not mix the `Z` variants with the non-`Z` variants.
Unfortunately the try/catch macros are needed to insert some magic to perform a trace during the unwinding search phase.
In order to have multiple catch alternatives, either `CPPTRACE_CATCH_ALT` or a normal `catch` must be used:
```cpp
CPPTRACE_TRY {
foo();
} CPPTRACE_CATCH(const std::exception&) { // <- First catch must be CPPTRACE_CATCH
// ...
} CPPTRACE_CATCH_ALT(const std::exception&) { // <- Ok
// ...
} catch(const std::exception&) { // <- Also Ok
// ...
} CPPTRACE_CATCH(const std::exception&) { // <- Not Ok
// ...
}
```
Note: The current exception is the exception most recently seen by a `CPPTRACE_CATCH` macro.
```cpp
CPPTRACE_TRY {
throw std::runtime_error("foo");
} CPPTRACE_CATCH(const std::exception& e) {
cpptrace::from_current_exception().print(); // the trace for std::runtime_error("foo")
CPPTRACE_TRY {
throw std::runtime_error("bar");
} CPPTRACE_CATCH(const std::exception& e) {
cpptrace::from_current_exception().print(); // the trace for std::runtime_error("bar")
}
cpptrace::from_current_exception().print(); // the trace for std::runtime_error("bar"), again
}
```
#### Removing the `CPPTRACE_` prefix
`CPPTRACE_TRY` is a little cumbersome to type. To remove the `CPPTRACE_` prefix you can use the
`CPPTRACE_UNPREFIXED_TRY_CATCH` cmake variable or define `CPPTRACE_UNPREFIXED_TRY_CATCH` for the preprocessor:
```cpp
TRY {
foo();
} CATCH(const std::exception& e) {
std::cerr<<"Exception: "<<e.what()<<std::endl;
cpptrace::from_current_exception().print();
}
```
This is not done by default for macro safety/hygiene reasons. If you do not want `TRY`/`CATCH` macros defined, as they
are common macro names, you can easily modify the following snippet to provide your own aliases:
```cpp
#define TRY CPPTRACE_TRY
#define CATCH(param) CPPTRACE_CATCH(param)
#define TRYZ CPPTRACE_TRYZ
#define CATCHZ(param) CPPTRACE_CATCHZ(param)
#define CATCH_ALT(param) CPPTRACE_CATCH_ALT(param)
```
#### How it works
C++ does not provide any language support for collecting stack traces when exceptions are thrown, however, exception
handling under both the Itanium ABI and by SEH (used to implement C++ exceptions on windows) involves unwinding the
stack twice, the first unwind searches for an appropriate `catch` handler, the second actually unwinds the stack and
stack twice. The first unwind searches for an appropriate `catch` handler, the second actually unwinds the stack and
calls destructors. Since the stack remains intact during the search phase it's possible to collect a stack trace with
zero overhead when the `catch` is considered for matching the exception.
zero overhead when the `catch` is considered for matching the exception. The try/catch macros for cpptrace set up a
special try/catch setup that can collect a stack trace when considered during a search phase.
N.b.: This mechanism is also discussed in [P2490R3][P2490R3].
#### Performance
`CPPTRACE_CATCH` internally generates lightweight raw traces when considered in the search phase. These are quite fast
to generate and are only resolved when `cpptrace::from_current_exception` is called.
The fundamental mechanism for this functionality is generating a trace when a catch block is considered during an
exception handler search phase. Internally a lightweight raw trace is generated upon consideration, which is quite
fast. This raw trace is only resolved when `cpptrace::from_current_exception` is called, or when the user manually
resolves a trace from `cpptrace::raw_trace_from_current_exception`.
Currently `CPPTRACE_CATCH` always generates a raw trace when considered as a candidate. That means that if there is a
nesting of handlers, either directly in code or as a result of the current call stack, the current stack may be traced
mutliple times until the appropriate handler is found.
It's tricky, however, from the library's standpoint to check if the catch will end up matching. The library could simply
generate a trace every time a `CPPTRACE_CATCH` is considered, however, in a deep nesting of catch's, e.g. as a result of
recusion, where a matching handler is not found quickly this could cause notable overhead due to tracing the stack
multiple times. Thus, there is a performance tradeoff between a little book keeping to prevent duplicate tracing or
biting the bullet, so to speak, in the throwing path and unwinding multiple times.
This should not matter for the vast majority applications given that performance very rarely is critical in throwing
paths, how exception handling is usually used, and the shallowness of most call stacks. However, it's something to be
aware of.
> [!TIP]
> The choice between the `Z` and non-`Z` (zero-overhead and non-zero-overhead) variants of the exception handlers should
> not matter 99% of the time, however, both are provided in the rare case that it does.
>
> `CPPTRACE_TRY`/`CPPTRACE_CATCH` could only hurt performance if used in a hot loop where the compiler can't optimize
> away the internal bookkeeping, otherwise the bookkeeping should be completely negligible.
>
> `CPPTRACE_TRYZ`/`CPPTRACE_CATCHZ` could only hurt performance when there is an exceptionally deep nesting of exception
> handlers in a call stack before a matching handler.
More information on performance considerations with the zero-overhead variant:
Tracing the stack multiple times in throwing paths should not matter for the vast majority applications given that:
1. Performance very rarely is critical in throwing paths and exceptions should be exceptionally rare
2. Exception handling is not usually used in such a way that you could have a deep nesting of handlers before finding a
matching handler
3. An that most call stacks are fairly shallow
To put the scale of this performance consideration into perspective: In my benchmarking I have found generation of raw
traces to take on the order of `100ns` per frame. Thus, even if there were 100 non-matching handlers before a matching
handler in a 100-deep call stack the total time would stil be on the order of one millisecond.
It's possible to avoid this by adding some bookkeeping to the `CPPTRACE_TRY` block. With the tradeoff between
zero-overhead try-catch in the happy path and a little extra overhead in the unhappy throwing path I decided to keep
try-catch zero-overhead. Should this be a concern to anyone, I'm happy to facilitate both solutions.
Nonetheless, I chose a default bookkeeping behavior for `CPPTRACE_TRY`/`CPPTRACE_CATCH` since it is safer with better
performance guarantees for the most general possible set of users.
### Traced Exception Objects

View File

@ -174,6 +174,7 @@ option(CPPTRACE_WERROR_BUILD "" OFF)
option(CPPTRACE_POSITION_INDEPENDENT_CODE "" ON)
option(CPPTRACE_SKIP_UNIT "" OFF)
option(CPPTRACE_STD_FORMAT "" ON)
option(CPPTRACE_UNPREFIXED_TRY_CATCH "" OFF)
option(CPPTRACE_USE_EXTERNAL_GTEST "" OFF)
set(CPPTRACE_ZSTD_REPO "https://github.com/facebook/zstd.git" CACHE STRING "")
set(CPPTRACE_ZSTD_TAG "794ea1b0afca0f020f4e57b6732332231fb23c70" CACHE STRING "") # v1.5.6

View File

@ -41,11 +41,19 @@ namespace cpptrace {
exception_unwind_interceptor(1);
return 0; // EXCEPTION_CONTINUE_SEARCH
}
CPPTRACE_FORCE_NO_INLINE inline int unconditional_exception_filter() {
collect_current_trace(1);
return 0; // EXCEPTION_CONTINUE_SEARCH
}
#else
class CPPTRACE_EXPORT unwind_interceptor {
public:
virtual ~unwind_interceptor();
};
class CPPTRACE_EXPORT unconditional_unwind_interceptor {
public:
virtual ~unconditional_unwind_interceptor();
};
CPPTRACE_EXPORT void do_prepare_unwind_interceptor(char(*)(std::size_t));
@ -81,6 +89,16 @@ namespace cpptrace {
} __except(::cpptrace::detail::exception_filter()) {} \
}(); \
} catch(param)
#define CPPTRACE_TRYZ \
try { \
[&]() { \
__try { \
[&]() {
#define CPPTRACE_CATCHZ(param) \
}(); \
} __except(::cpptrace::detail::unconditional_exception_filter()) {} \
}(); \
} catch(param)
#else
#define CPPTRACE_TRY \
try { \
@ -92,6 +110,22 @@ namespace cpptrace {
#define CPPTRACE_CATCH(param) \
} catch(::cpptrace::detail::unwind_interceptor&) {} \
} catch(param)
#define CPPTRACE_TRYZ \
try { \
try {
#define CPPTRACE_CATCHZ(param) \
} catch(::cpptrace::detail::unconditional_unwind_interceptor&) {} \
} catch(param)
#endif
#define CPPTRACE_CATCH_ALT(param) catch(param)
#ifdef CPPTRACE_UNPREFIXED_TRY_CATCH
#define TRY CPPTRACE_TRY
#define CATCH(param) CPPTRACE_CATCH(param)
#define TRYZ CPPTRACE_TRYZ
#define CATCHZ(param) CPPTRACE_CATCHZ(param)
#define CATCH_ALT(param) CPPTRACE_CATCH_ALT(param)
#endif
#endif

View File

@ -48,7 +48,16 @@ namespace cpptrace {
return false;
}
CPPTRACE_FORCE_NO_INLINE
bool unconditional_exception_unwind_interceptor(const std::type_info*, const std::type_info*, void**, unsigned) {
collect_current_trace(1);
return false;
}
using do_catch_fn = decltype(intercept_unwind);
unwind_interceptor::~unwind_interceptor() = default;
unconditional_unwind_interceptor::~unconditional_unwind_interceptor() = default;
#if IS_LIBSTDCXX
constexpr size_t vtable_size = 11;
@ -194,10 +203,7 @@ namespace cpptrace {
}
#endif
// allocated below, cleaned up by OS after exit
void* new_vtable_page = nullptr;
void perform_typeinfo_surgery(const std::type_info& info) {
void perform_typeinfo_surgery(const std::type_info& info, do_catch_fn* do_catch_function) {
if(vtable_size == 0) { // set to zero if we don't know what standard library we're working with
return;
}
@ -243,12 +249,13 @@ namespace cpptrace {
}
// allocate a page for the new vtable so it can be made read-only later
new_vtable_page = allocate_page(page_size);
// the OS cleans this up, no cleanup done here for it
void* new_vtable_page = allocate_page(page_size);
// make our own copy of the vtable
memcpy(new_vtable_page, type_info_vtable_pointer, vtable_size * sizeof(void*));
// ninja in the custom __do_catch interceptor
auto new_vtable = static_cast<void**>(new_vtable_page);
new_vtable[6] = reinterpret_cast<void*>(intercept_unwind);
new_vtable[6] = reinterpret_cast<void*>(do_catch_function);
// make the page read-only
mprotect_page(new_vtable_page, page_size, memory_readonly);
@ -273,7 +280,11 @@ namespace cpptrace {
if(!did_prepare) {
cpptrace::detail::intercept_unwind_handler = intercept_unwind_handler;
try {
perform_typeinfo_surgery(typeid(cpptrace::detail::unwind_interceptor));
perform_typeinfo_surgery(typeid(cpptrace::detail::unwind_interceptor), intercept_unwind);
perform_typeinfo_surgery(
typeid(cpptrace::detail::unconditional_unwind_interceptor),
unconditional_exception_unwind_interceptor
);
} catch(std::exception& e) {
std::fprintf(
stderr,

View File

@ -84,6 +84,7 @@ if(NOT CPPTRACE_SKIP_UNIT)
unit/object_trace.cpp
unit/stacktrace.cpp
unit/from_current.cpp
unit/from_current_z.cpp
unit/traced_exception.cpp
)
target_compile_features(unittest PRIVATE cxx_std_20)

View File

@ -0,0 +1,143 @@
#include <algorithm>
#include <iomanip>
#include <sstream>
#include <string_view>
#include <string>
#include <gtest/gtest.h>
#include <gtest/gtest-matchers.h>
#include <gmock/gmock.h>
#include <gmock/gmock-matchers.h>
#include <cpptrace/cpptrace.hpp>
#include <cpptrace/from_current.hpp>
using namespace std::literals;
// NOTE: returning something and then return stacktrace_from_current_3(line_numbers) * 2; later helps prevent the call from
// being optimized to a jmp
CPPTRACE_FORCE_NO_INLINE int stacktrace_from_current_z_3(std::vector<int>& line_numbers) {
line_numbers.insert(line_numbers.begin(), __LINE__ + 1);
throw std::runtime_error("foobar");
}
CPPTRACE_FORCE_NO_INLINE int stacktrace_from_current_z_2(std::vector<int>& line_numbers) {
line_numbers.insert(line_numbers.begin(), __LINE__ + 1);
return stacktrace_from_current_z_3(line_numbers) * 2;
}
CPPTRACE_FORCE_NO_INLINE int stacktrace_from_current_z_1(std::vector<int>& line_numbers) {
line_numbers.insert(line_numbers.begin(), __LINE__ + 1);
return stacktrace_from_current_z_2(line_numbers) * 2;
}
TEST(FromCurrentZ, Basic) {
std::vector<int> line_numbers;
CPPTRACE_TRYZ {
line_numbers.insert(line_numbers.begin(), __LINE__ + 1);
stacktrace_from_current_z_1(line_numbers);
} CPPTRACE_CATCHZ(const std::runtime_error& e) {
EXPECT_EQ(e.what(), "foobar"sv);
const auto& trace = cpptrace::from_current_exception();
ASSERT_GE(trace.frames.size(), 4);
auto it = std::find_if(
trace.frames.begin(),
trace.frames.end(),
[](const cpptrace::stacktrace_frame& frame) {
return frame.filename.find("from_current_z.cpp") != std::string::npos
&& frame.symbol.find("lambda") == std::string::npos; // due to msvc
}
);
ASSERT_NE(it, trace.frames.end());
size_t i = static_cast<size_t>(it - trace.frames.begin());
int j = 0;
ASSERT_LT(i, trace.frames.size());
ASSERT_LT(j, line_numbers.size());
EXPECT_THAT(trace.frames[i].filename, testing::EndsWith("from_current_z.cpp"));
EXPECT_EQ(trace.frames[i].line.value(), line_numbers[j]);
EXPECT_THAT(trace.frames[i].symbol, testing::HasSubstr("stacktrace_from_current_z_3"));
i++;
j++;
ASSERT_LT(i, trace.frames.size());
ASSERT_LT(j, line_numbers.size());
EXPECT_THAT(trace.frames[i].filename, testing::EndsWith("from_current_z.cpp"));
EXPECT_EQ(trace.frames[i].line.value(), line_numbers[j]);
EXPECT_THAT(trace.frames[i].symbol, testing::HasSubstr("stacktrace_from_current_z_2"));
i++;
j++;
ASSERT_LT(i, trace.frames.size());
ASSERT_LT(j, line_numbers.size());
EXPECT_THAT(trace.frames[i].filename, testing::EndsWith("from_current_z.cpp"));
EXPECT_EQ(trace.frames[i].line.value(), line_numbers[j]);
EXPECT_THAT(trace.frames[i].symbol, testing::HasSubstr("stacktrace_from_current_z_1"));
i++;
j++;
ASSERT_LT(i, trace.frames.size());
ASSERT_LT(j, line_numbers.size());
EXPECT_THAT(trace.frames[i].filename, testing::EndsWith("from_current_z.cpp"));
EXPECT_EQ(trace.frames[i].line.value(), line_numbers[j]);
EXPECT_THAT(trace.frames[i].symbol, testing::HasSubstr("FromCurrentZ_Basic_Test::TestBody"));
}
}
TEST(FromCurrentZ, CorrectHandler) {
std::vector<int> line_numbers;
CPPTRACE_TRYZ {
CPPTRACE_TRYZ {
line_numbers.insert(line_numbers.begin(), __LINE__ + 1);
stacktrace_from_current_z_1(line_numbers);
} CPPTRACE_CATCHZ(const std::logic_error&) {
FAIL();
}
} CPPTRACE_CATCHZ(const std::exception& e) {
EXPECT_EQ(e.what(), "foobar"sv);
const auto& trace = cpptrace::from_current_exception();
auto it = std::find_if(
trace.frames.begin(),
trace.frames.end(),
[](const cpptrace::stacktrace_frame& frame) {
return frame.filename.find("from_current_z.cpp") != std::string::npos
&& frame.symbol.find("lambda") == std::string::npos;
}
);
EXPECT_NE(it, trace.frames.end());
it = std::find_if(
trace.frames.begin(),
trace.frames.end(),
[](const cpptrace::stacktrace_frame& frame) {
return frame.symbol.find("FromCurrentZ_CorrectHandler_Test::TestBody") != std::string::npos;
}
);
EXPECT_NE(it, trace.frames.end());
}
}
TEST(FromCurrentZ, RawTrace) {
std::vector<int> line_numbers;
CPPTRACE_TRYZ {
line_numbers.insert(line_numbers.begin(), __LINE__ + 1);
stacktrace_from_current_z_1(line_numbers);
} CPPTRACE_CATCHZ(const std::exception& e) {
EXPECT_EQ(e.what(), "foobar"sv);
const auto& raw_trace = cpptrace::raw_trace_from_current_exception();
auto trace = raw_trace.resolve();
auto it = std::find_if(
trace.frames.begin(),
trace.frames.end(),
[](const cpptrace::stacktrace_frame& frame) {
return frame.filename.find("from_current_z.cpp") != std::string::npos
&& frame.symbol.find("lambda") == std::string::npos;
}
);
EXPECT_NE(it, trace.frames.end());
it = std::find_if(
trace.frames.begin(),
trace.frames.end(),
[](const cpptrace::stacktrace_frame& frame) {
return frame.symbol.find("FromCurrentZ_RawTrace_Test::TestBody") != std::string::npos;
}
);
EXPECT_NE(it, trace.frames.end());
}
}