Repository navigation
Compiler-instrumentation backend #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fe11675
3043a9f
987d0f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| add_executable(cyclescope_demo demo.cpp) | ||
| target_link_libraries(cyclescope_demo PRIVATE cyclescope::cyclescope | ||
| cyclescope::instrument) | ||
| target_compile_options(cyclescope_demo PRIVATE -finstrument-functions | ||
| -Wall -Wextra) | ||
| set_target_properties(cyclescope_demo PROPERTIES ENABLE_EXPORTS ON) | ||
| if(CYCLESCOPE_SANITIZE) | ||
| target_compile_options(cyclescope_demo PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) | ||
| target_link_options(cyclescope_demo PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE}) | ||
| endif() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| // Instrumented demo: every function call in this file becomes a trace | ||
| // event. Build is compiled with -finstrument-functions and linked against | ||
| // cyclescope::instrument; running it writes demo.trace.json, which loads | ||
| // in the Perfetto UI (ui.perfetto.dev) or chrome://tracing. | ||
|
|
||
| #include <cstdio> | ||
| #include <cyclescope/trace.hpp> | ||
|
|
||
| namespace { | ||
|
|
||
| __attribute__((noinline)) long fib(int n) { | ||
| return n < 2 ? n : fib(n - 1) + fib(n - 2); | ||
| } | ||
|
|
||
| __attribute__((noinline)) long work(void) { | ||
| long total = 0; | ||
| for (int i = 0; i < 18; ++i) { | ||
| total += fib(i); | ||
| } | ||
| return total; | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| int main() { | ||
| const long total = work(); | ||
| std::uint64_t dropped = 0; | ||
| const bool ok = | ||
| cyclescope::collector::instance().write_json("demo.trace.json", | ||
| &dropped); | ||
| std::printf("fib total %ld; trace %s, %llu dropped\n", total, | ||
| ok ? "written to demo.trace.json" : "FAILED", | ||
| static_cast<unsigned long long>(dropped)); | ||
| return ok ? 0 : 1; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,39 @@ namespace detail { | |
| // thread's nontrivial thread_locals (including the buffer handle) are | ||
| // gone. Set during TLS teardown; record() then drops late events. | ||
| inline thread_local bool tls_shutdown = false; | ||
|
|
||
| // True while collector internals run on this thread. Instrumentation | ||
| // hooks check it and stand down, so a hook firing inside record() or | ||
| // write_json() can never re-enter a lock this thread already holds. | ||
| inline thread_local bool tls_suppress = false; | ||
|
|
||
| // The constructor and destructor are forced inline: an out-of-line copy | ||
| // emitted by an instrumented translation unit could be selected by the | ||
| // linker, and its own entry would fire a hook before the flag is set, | ||
| // recursing without bound in unoptimized builds. | ||
| #if defined(_MSC_VER) | ||
| #define CYCLESCOPE_ALWAYS_INLINE __forceinline | ||
| #define CYCLESCOPE_NO_INSTRUMENT | ||
| #else | ||
| #define CYCLESCOPE_ALWAYS_INLINE __attribute__((always_inline)) inline | ||
| #define CYCLESCOPE_NO_INSTRUMENT __attribute__((no_instrument_function)) | ||
| #endif | ||
|
|
||
| struct suppress_scope { | ||
| bool previous; | ||
| CYCLESCOPE_ALWAYS_INLINE CYCLESCOPE_NO_INSTRUMENT suppress_scope() noexcept | ||
| : previous(tls_suppress) { | ||
| tls_suppress = true; | ||
| } | ||
| suppress_scope(const suppress_scope&) = delete; | ||
| suppress_scope& operator=(const suppress_scope&) = delete; | ||
| CYCLESCOPE_ALWAYS_INLINE CYCLESCOPE_NO_INSTRUMENT ~suppress_scope() { | ||
| tls_suppress = previous; | ||
| } | ||
| }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Belt-and-suspenders: The comment explains the correctness argument: However, if an instrumented TU happens to emit an out-of-line copy (e.g. via a pointer-to-member, a virtual dispatch, or a compiler that declines to inline in certain debug/LTO paths), the entry hook would fire before Adding struct suppress_scope {
bool previous;
CYCLESCOPE_ALWAYS_INLINE __attribute__((no_instrument_function))
suppress_scope() noexcept : previous(tls_suppress) {
tls_suppress = true;
}
suppress_scope(const suppress_scope&) = delete;
suppress_scope& operator=(const suppress_scope&) = delete;
CYCLESCOPE_ALWAYS_INLINE __attribute__((no_instrument_function))
~suppress_scope() { tls_suppress = previous; }
};This is a defence-in-depth change, not a fix for a currently-triggered bug. |
||
|
|
||
| #undef CYCLESCOPE_ALWAYS_INLINE | ||
| #undef CYCLESCOPE_NO_INSTRUMENT | ||
| } // namespace detail | ||
|
|
||
| struct trace_event { | ||
|
|
@@ -50,9 +83,13 @@ inline std::uint64_t trace_now_ns() { | |
|
|
||
| class collector { | ||
| public: | ||
| // Deliberately immortal (never destroyed): instrumentation hooks and | ||
| // traced destructors can run during process teardown, and a destroyed | ||
| // collector would turn those into use-after-free. The buffers are | ||
| // reclaimed by the operating system at process exit. | ||
| static collector& instance() { | ||
| static collector self; | ||
| return self; | ||
| static collector* self = new collector(); | ||
| return *self; | ||
| } | ||
|
|
||
| void set_capacity_per_thread(std::size_t capacity) { | ||
|
|
@@ -69,6 +106,7 @@ class collector { | |
| // destroyed; the event is dropped rather than touching dead TLS. | ||
| return; | ||
| } | ||
| detail::suppress_scope suppress; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same suppress_scope recursion risk applies to all three inline collector methods
The cleanest fix across the board is to mark both struct suppress_scope {
bool previous;
[[gnu::always_inline]] suppress_scope() noexcept
: previous(tls_suppress) { tls_suppress = true; }
suppress_scope(const suppress_scope&) = delete;
suppress_scope& operator=(const suppress_scope&) = delete;
[[gnu::always_inline]] ~suppress_scope() { tls_suppress = previous; }
};This guarantees no call instruction is emitted for the guard regardless of TU, optimization level, or linker symbol selection. |
||
| thread_buffer& mine = local_buffer(); | ||
| std::lock_guard guard(mine.mutex); | ||
| if (mine.events.size() >= capacity_.load(std::memory_order_relaxed)) { | ||
|
|
@@ -91,6 +129,7 @@ class collector { | |
| // snapshotted first (a transient copy of the event data), so recording | ||
| // threads and thread registration never stall behind a slow disk. | ||
| bool write_json(const char* path, std::uint64_t* dropped_total = nullptr) { | ||
| detail::suppress_scope suppress; | ||
| std::vector<std::shared_ptr<thread_buffer>> registered; | ||
| { | ||
| std::lock_guard registry_guard(registry_mutex_); | ||
|
|
@@ -152,6 +191,7 @@ class collector { | |
| // runs so their events still reach the trace; long-running programs with | ||
| // thread churn should clear() after each flush. | ||
| void clear() { | ||
| detail::suppress_scope suppress; | ||
| std::lock_guard registry_guard(registry_mutex_); | ||
| std::vector<std::shared_ptr<thread_buffer>> kept; | ||
| kept.reserve(buffers_.size()); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,135 @@ | ||||||||
| // Compiler-instrumentation backend: implements the hooks that | ||||||||
| // -finstrument-functions inserts at every function entry and exit, and | ||||||||
| // records each call as a complete event in the cyclescope collector. | ||||||||
| // | ||||||||
| // This translation unit must be compiled WITHOUT -finstrument-functions | ||||||||
| // (the build system guarantees it); the attribute on each hook is a second | ||||||||
| // line of defense. Usage: compile the code to trace with | ||||||||
| // -finstrument-functions and link cyclescope::instrument. | ||||||||
| // | ||||||||
| // Names resolve lazily: the first return from a given function looks it up | ||||||||
| // with dladdr, demangles it when possible, and interns the string for the | ||||||||
| // process lifetime (the collector stores name pointers, not copies). | ||||||||
| // Symbols outside the dynamic symbol table fall back to their address; on | ||||||||
| // Linux, executables should enable exports (CMake ENABLE_EXPORTS) so | ||||||||
| // dladdr can see them. | ||||||||
|
|
||||||||
| #include <cstdint> | ||||||||
| #include <cstdio> | ||||||||
| #include <cstring> | ||||||||
| #include <cyclescope/trace.hpp> | ||||||||
| #include <mutex> | ||||||||
| #include <unordered_map> | ||||||||
|
|
||||||||
| #if !defined(_WIN32) | ||||||||
| #include <dlfcn.h> | ||||||||
| #endif | ||||||||
| #if defined(__has_include) | ||||||||
| #if __has_include(<cxxabi.h>) | ||||||||
| #include <cxxabi.h> | ||||||||
| #define CYCLESCOPE_HAS_CXXABI 1 | ||||||||
| #endif | ||||||||
| #endif | ||||||||
|
|
||||||||
| namespace { | ||||||||
|
|
||||||||
| constexpr int kMaxDepth = 512; | ||||||||
|
|
||||||||
| struct frame { | ||||||||
| void* fn; | ||||||||
| std::uint64_t start_ns; | ||||||||
| }; | ||||||||
|
|
||||||||
| // Trivially destructible on purpose: hooks can run during TLS teardown, | ||||||||
| // and POD thread_local storage stays readable there. | ||||||||
| struct frame_stack { | ||||||||
| int depth = 0; | ||||||||
| int beyond = 0; // Calls deeper than kMaxDepth, tracked but not recorded. | ||||||||
| }; | ||||||||
|
|
||||||||
| thread_local frame_stack tls_stack; | ||||||||
| thread_local frame tls_frames[kMaxDepth]; | ||||||||
|
|
||||||||
| const char* interned_name(void* fn) { | ||||||||
| // Deliberately immortal, mutex included: exit hooks can fire during | ||||||||
| // process teardown, after function-local statics with destructors are | ||||||||
| // gone, and locking a destroyed mutex throws from a noexcept context. | ||||||||
| static auto* mutex = new std::mutex(); | ||||||||
| static auto* names = new std::unordered_map<void*, const char*>(); | ||||||||
|
|
||||||||
| std::lock_guard guard(*mutex); | ||||||||
| if (const auto it = names->find(fn); it != names->end()) { | ||||||||
| return it->second; | ||||||||
| } | ||||||||
|
|
||||||||
| const char* resolved = nullptr; | ||||||||
| #if !defined(_WIN32) | ||||||||
| Dl_info info; | ||||||||
| if (dladdr(fn, &info) != 0 && info.dli_sname != nullptr) { | ||||||||
| #if defined(CYCLESCOPE_HAS_CXXABI) | ||||||||
| int status = -1; | ||||||||
| char* demangled = | ||||||||
| abi::__cxa_demangle(info.dli_sname, nullptr, nullptr, &status); | ||||||||
| resolved = (status == 0 && demangled != nullptr) | ||||||||
| ? demangled | ||||||||
| : ::strdup(info.dli_sname); | ||||||||
| #else | ||||||||
| resolved = ::strdup(info.dli_sname); | ||||||||
| #endif | ||||||||
| } | ||||||||
| #endif | ||||||||
| if (resolved == nullptr) { | ||||||||
| char buffer[32]; | ||||||||
| std::snprintf(buffer, sizeof(buffer), "fn@%p", fn); | ||||||||
| resolved = ::strdup(buffer); | ||||||||
| } | ||||||||
| names->emplace(fn, resolved); | ||||||||
| return resolved; | ||||||||
| } | ||||||||
|
|
||||||||
| } // namespace | ||||||||
|
|
||||||||
| extern "C" { | ||||||||
|
|
||||||||
| // Both hooks stand down while the suppress flag is set: it covers hook | ||||||||
| // reentrancy AND collector internals. Without the latter, an instrumented | ||||||||
| // copy of an inline collector function (the linker may pick one from an | ||||||||
| // instrumented translation unit) fires a hook while this thread already | ||||||||
| // holds a collector mutex, and record() self-deadlocks. | ||||||||
| __attribute__((no_instrument_function)) void __cyg_profile_func_enter( | ||||||||
| void* fn, void* /*call_site*/) { | ||||||||
| if (cyclescope::detail::tls_suppress) { | ||||||||
| return; | ||||||||
| } | ||||||||
| cyclescope::detail::suppress_scope suppress; | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential infinite recursion in debug builds
Consequence: entering that instrumented constructor fires The same risk applies at line 119 in the exit hook. Because the hooks are Fix – avoid the function call entirely:
Suggested change
Then at the end of the hook body (before the closing Alternatively, add |
||||||||
| frame_stack& stack = tls_stack; | ||||||||
| if (stack.beyond > 0 || stack.depth == kMaxDepth) { | ||||||||
| ++stack.beyond; | ||||||||
| } else { | ||||||||
| tls_frames[stack.depth] = {fn, cyclescope::trace_now_ns()}; | ||||||||
| ++stack.depth; | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| __attribute__((no_instrument_function)) void __cyg_profile_func_exit( | ||||||||
| void* fn, void* /*call_site*/) { | ||||||||
| if (cyclescope::detail::tls_suppress) { | ||||||||
| return; | ||||||||
| } | ||||||||
| cyclescope::detail::suppress_scope suppress; | ||||||||
| frame_stack& stack = tls_stack; | ||||||||
| if (stack.beyond > 0) { | ||||||||
| --stack.beyond; | ||||||||
| } else if (stack.depth > 0) { | ||||||||
| --stack.depth; | ||||||||
| const frame entered = tls_frames[stack.depth]; | ||||||||
| // Longjmp or exceptions can skip exits; only record matched frames. | ||||||||
| if (entered.fn == fn) { | ||||||||
| cyclescope::collector::instance().record( | ||||||||
| interned_name(fn), entered.start_ns, | ||||||||
| cyclescope::trace_now_ns() - entered.start_ns); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| } // extern "C" | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,3 +30,26 @@ endfunction() | |
|
|
||
| cyclescope_add_test(log_test) | ||
| cyclescope_add_test(trace_test) | ||
|
|
||
| # The instrumentation backend test: only the fixture translation unit is | ||
| # compiled with -finstrument-functions, so the assertions run over a known | ||
| # call set instead of every doctest internal. ENABLE_EXPORTS lets dladdr | ||
| # resolve the fixture symbols from the executable on Linux. | ||
| if(NOT MSVC) | ||
| add_executable(instrument_test instrument_test.cpp instrument_fixture.cpp) | ||
| set_source_files_properties(instrument_fixture.cpp PROPERTIES | ||
| COMPILE_OPTIONS "-finstrument-functions") | ||
| target_link_libraries(instrument_test PRIVATE cyclescope::cyclescope | ||
| cyclescope::instrument doctest::doctest Threads::Threads) | ||
| target_compile_options(instrument_test PRIVATE -Wall -Wextra) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When The top-level if(CYCLESCOPE_SANITIZE)
target_compile_options(cyclescope_instrument PUBLIC
-fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer)
target_link_options(cyclescope_instrument PUBLIC
-fsanitize=${CYCLESCOPE_SANITIZE})
endif()(Use |
||
| set_target_properties(instrument_test PROPERTIES ENABLE_EXPORTS ON) | ||
| if(CYCLESCOPE_SANITIZE) | ||
| target_compile_options(instrument_test PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) | ||
| target_link_options(instrument_test PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE}) | ||
| endif() | ||
| add_test(NAME instrument_test COMMAND instrument_test) | ||
| # A suppression regression deadlocks rather than fails; bound it. | ||
| set_tests_properties(instrument_test PROPERTIES TIMEOUT 120) | ||
| endif() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // Compiled WITH -finstrument-functions (see tests/CMakeLists.txt); the | ||
| // hooks in cyclescope::instrument trace every call made here. extern "C" | ||
| // keeps the symbol names stable for the assertions. | ||
|
|
||
| #include <cyclescope/trace.hpp> | ||
|
|
||
| extern "C" { | ||
|
|
||
| // Regression shape: an instrumented translation unit instantiates the | ||
| // inline collector functions, the linker may select those copies | ||
| // program-wide, and a hook firing inside them must stand down instead of | ||
| // re-entering a lock this thread holds. | ||
| __attribute__((noinline)) int cyclescope_fixture_flush(const char* path) { | ||
| return cyclescope::collector::instance().write_json(path) ? 1 : 0; | ||
| } | ||
|
|
||
| __attribute__((noinline)) void cyclescope_fixture_leaf(void) { | ||
| asm volatile(""); // Keeps the call from being optimized away. | ||
| } | ||
|
|
||
| __attribute__((noinline)) void cyclescope_fixture_parent(void) { | ||
| for (int i = 0; i < 3; ++i) { | ||
| cyclescope_fixture_leaf(); | ||
| } | ||
| } | ||
|
|
||
| } // extern "C" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CYCLESCOPE_ALWAYS_INLINEmacro leaks into consumer namespace.The macro is defined in a public header but never
#undef'd. Any translation unit that includes<cyclescope/trace.hpp>inherits it and could conflict with a consumer-defined macro of the same name. Add#undef CYCLESCOPE_ALWAYS_INLINEimmediately after thesuppress_scopestruct that uses it:(with a
#undef CYCLESCOPE_ALWAYS_INLINEinserted after the closing brace ofsuppress_scope)