Repository navigation
Design write-up with overhead measurements #8
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| add_executable(overhead_bench overhead_bench.cpp overhead_fib.cpp) | ||
| set_source_files_properties(overhead_fib.cpp PROPERTIES | ||
| COMPILE_OPTIONS "-finstrument-functions") | ||
| target_link_libraries(overhead_bench PRIVATE cyclescope::cyclescope | ||
| cyclescope::instrument) | ||
| target_compile_options(overhead_bench PRIVATE -Wall -Wextra) | ||
| set_target_properties(overhead_bench PROPERTIES ENABLE_EXPORTS ON) | ||
| if(CYCLESCOPE_SANITIZE) | ||
| target_compile_options(overhead_bench PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) | ||
| target_link_options(overhead_bench PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE}) | ||
| endif() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // Per-event cost of the two tracing paths, measured as the difference | ||
| // against an untraced twin of the same work. This translation unit is | ||
| // compiled WITHOUT instrumentation; the instrumented fib twin lives in | ||
| // overhead_fib.cpp. | ||
| // | ||
| // Usage: overhead_bench [scope_iterations] [fib_n] | ||
|
|
||
| #include <chrono> | ||
| #include <cstdint> | ||
| #include <cstdio> | ||
| #include <cstdlib> | ||
| #include <cyclescope/trace.hpp> | ||
|
|
||
| extern "C" long cyclescope_bench_fib_instrumented(int n); | ||
|
|
||
| namespace { | ||
|
|
||
| using bench_clock = std::chrono::steady_clock; | ||
|
|
||
| double seconds_since(bench_clock::time_point start) { | ||
| return std::chrono::duration<double>(bench_clock::now() - start).count(); | ||
| } | ||
|
|
||
| __attribute__((noinline)) long fib_plain(int n) { | ||
| return n < 2 ? n : fib_plain(n - 1) + fib_plain(n - 2); | ||
| } | ||
|
|
||
| // Total calls made by the recursive fib: 2 * fib(n + 1) - 1. | ||
| long fib_call_count(int n) { | ||
| long a = 0; | ||
| long b = 1; | ||
| for (int i = 0; i < n; ++i) { | ||
| const long next = a + b; | ||
| a = b; | ||
| b = next; | ||
| } | ||
| return 2 * b - 1; | ||
| } | ||
|
|
||
| void measure_scope_macro(std::int64_t iterations) { | ||
| auto& collector = cyclescope::collector::instance(); | ||
| collector.clear(); | ||
| collector.set_capacity_per_thread( | ||
| static_cast<std::size_t>(iterations) + 1024); | ||
|
|
||
| const auto baseline_start = bench_clock::now(); | ||
| for (std::int64_t i = 0; i < iterations; ++i) { | ||
| asm volatile(""); | ||
| } | ||
| const double baseline = seconds_since(baseline_start); | ||
|
|
||
| const auto traced_start = bench_clock::now(); | ||
| for (std::int64_t i = 0; i < iterations; ++i) { | ||
| CYCLESCOPE_SCOPE("overhead"); | ||
| } | ||
| const double traced = seconds_since(traced_start); | ||
|
|
||
| std::printf("scope macro %8.1f ns/event (%lld events, %.3f s)\n", | ||
| (traced - baseline) / static_cast<double>(iterations) * 1e9, | ||
| static_cast<long long>(iterations), traced); | ||
| collector.clear(); | ||
| collector.set_capacity_per_thread(std::size_t{1} << 20); | ||
| } | ||
|
|
||
| void measure_instrumentation(int fib_n) { | ||
| auto& collector = cyclescope::collector::instance(); | ||
| collector.clear(); | ||
| const long calls = fib_call_count(fib_n); | ||
| collector.set_capacity_per_thread(static_cast<std::size_t>(calls) + 1024); | ||
|
|
||
| const auto plain_start = bench_clock::now(); | ||
| const long plain_result = fib_plain(fib_n); | ||
| const double plain = seconds_since(plain_start); | ||
|
|
||
| const auto traced_start = bench_clock::now(); | ||
| const long traced_result = cyclescope_bench_fib_instrumented(fib_n); | ||
| const double traced = seconds_since(traced_start); | ||
|
|
||
| if (plain_result != traced_result) { | ||
| std::fprintf(stderr, "instrumented fib diverged\n"); | ||
| std::exit(1); | ||
| } | ||
| std::printf( | ||
| "instrumentation %8.1f ns/call (%ld calls, plain %.3f s, traced " | ||
| "%.3f s)\n", | ||
| (traced - plain) / static_cast<double>(calls) * 1e9, calls, plain, | ||
| traced); | ||
| collector.clear(); | ||
| collector.set_capacity_per_thread(std::size_t{1} << 20); | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| int main(int argc, char** argv) { | ||
| const std::int64_t iterations = | ||
| argc > 1 ? std::strtoll(argv[1], nullptr, 10) : 1000000; | ||
| const int fib_n = argc > 2 ? std::atoi(argv[2]) : 26; | ||
| if (iterations <= 0 || fib_n < 1 || fib_n > 40) { | ||
| std::fprintf(stderr, | ||
| "usage: overhead_bench [iterations > 0] [1 <= fib_n <= 40]\n"); | ||
| return 1; | ||
| } | ||
| std::printf("overhead_bench: %lld scope iterations, fib(%d)\n", | ||
| static_cast<long long>(iterations), fib_n); | ||
| measure_scope_macro(iterations); | ||
| measure_instrumentation(fib_n); | ||
| return 0; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // Compiled WITH -finstrument-functions: the instrumented twin of the | ||
| // benchmark's plain fib, so the measured difference is the hook cost. | ||
|
|
||
| extern "C" { | ||
|
|
||
| __attribute__((noinline)) long cyclescope_bench_fib_instrumented(int n) { | ||
| return n < 2 ? n | ||
| : cyclescope_bench_fib_instrumented(n - 1) + | ||
| cyclescope_bench_fib_instrumented(n - 2); | ||
| } | ||
|
|
||
| } // extern "C" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| # cyclescope design notes | ||
|
|
||
| cyclescope is a small C++20 scope and function tracing library. Threads | ||
| record complete events into per-thread buffers; the collector writes chrome | ||
| trace-event JSON that the Perfetto UI (ui.perfetto.dev) and chrome://tracing | ||
| load directly. Two front ends feed the same core: an explicit RAII scope | ||
| macro, and a compiler-instrumentation backend that traces every function | ||
| call in translation units built with -finstrument-functions. | ||
|
|
||
| ## Core: per-thread buffers behind one collector | ||
|
|
||
| Each recording thread owns a buffer guarded by its own mutex, uncontended | ||
| except while a flush snapshots it, so recording threads never contend with | ||
| each other. Buffers are bounded (default 2^20 events per thread) and | ||
| overflow drops events with a count that `write_json` reports; a tracing | ||
| library that silently grows without bound or silently loses data is worse | ||
| than one that tells you which happened. | ||
|
|
||
| The registry keeps a buffer alive after its thread exits so the events | ||
| still reach the trace; `clear()` prunes dead buffers, and thread ids come | ||
| from a monotonic 64-bit counter so pruning never recycles them. `record()` | ||
| is `noexcept`: it is called from destructors, and an allocation failure | ||
| counts as a dropped event instead of terminating the program. | ||
|
|
||
| `write_json` snapshots the registry and every buffer first, then performs | ||
| all file I/O with no lock held, so a slow disk cannot stall recording | ||
| threads or thread registration. The JSON footer is written whenever the | ||
| header succeeded, keeping the on-disk file syntactically complete even | ||
| after a mid-write failure. | ||
|
|
||
| Event names are `const char*` with required static lifetime (string | ||
| literals, `__func__`, or interned strings). Storing pointers rather than | ||
| copies keeps the record path at one small struct append. | ||
|
|
||
| ## The suppression flag: tracing must never trace itself | ||
|
|
||
| The deepest design constraint comes from how -finstrument-functions | ||
| interacts with inline functions and the one-definition rule. Every inline | ||
| collector function instantiated in an instrumented translation unit exists | ||
| there as an instrumented copy, and the linker may select that copy for the | ||
| whole program. Two failure modes follow: | ||
|
|
||
| - A hook fires inside `write_json` while the thread holds a buffer mutex; | ||
| the hook calls `record()`, which relocks the same mutex. Self-deadlock. | ||
| - In unoptimized builds, an out-of-line instrumented copy of the guard | ||
| object's own constructor fires a hook before the guard is set, and the | ||
| hook constructs another guard. Unbounded recursion. | ||
|
|
||
| The defense is a thread-local suppression flag with three properties: | ||
| `record()`, `write_json()`, and `clear()` set it for their duration, both | ||
| hooks check it first and stand down, and the guard's constructor and | ||
| destructor are forced inline and marked non-instrumentable so no callable | ||
| copy of them can ever exist. A regression test compiles a fixture with | ||
| instrumentation and flushes from inside it, the exact deadlock shape, | ||
| bounded by a test timeout so a regression fails instead of hanging. | ||
|
|
||
| Teardown is the other lifecycle hazard: hooks and traced destructors can | ||
| run after static destruction has begun, so the collector singleton and the | ||
| name-interning table and mutex are deliberately immortal. The operating | ||
| system reclaims them at process exit; nothing a late hook touches is ever | ||
| a destroyed object. A trivially destructible thread-local flag likewise | ||
| marks TLS teardown, after which late events on that thread drop safely. | ||
|
|
||
| ## Instrumentation backend | ||
|
|
||
| The hooks live in a separately compiled, uninstrumented static library. | ||
| Per-thread state is a fixed-depth (512) POD frame stack: no allocation | ||
| inside hooks, trivially destructible, safe during TLS teardown. Calls | ||
| deeper than the cap are counted and skipped, and an exit whose function | ||
| pointer does not match the frame it would close (longjmp, exceptions) is | ||
| discarded rather than miscounted. | ||
|
|
||
| Names resolve lazily: the first return from a given function looks it up | ||
| with dladdr, demangles when possible, and interns the string for the | ||
| process lifetime, so the steady-state cost per event is a mutex-guarded | ||
| hash lookup rather than symbol resolution. Symbols outside the dynamic | ||
| symbol table fall back to their address; executables on Linux should set | ||
| ENABLE_EXPORTS so dladdr can see their symbols. MSVC has no | ||
| -finstrument-functions, so the backend is not built there; the scope macro | ||
| front end remains fully portable. | ||
|
|
||
| ## Emitter choice | ||
|
|
||
| Chrome trace-event JSON with `ph:"X"` complete events is the smallest | ||
| format that a first-class interactive viewer consumes with zero tooling on | ||
| our side: one JSON file, drag it into Perfetto. Timestamps and durations | ||
| are microseconds with nanosecond-resolution inputs. The format costs more | ||
| bytes per event than a binary protocol, which is acceptable at the event | ||
| rates a scope tracer produces; a binary emitter can be added behind the | ||
| same collector if a use case demands it. | ||
|
|
||
| ## Measured overhead | ||
|
|
||
| One machine, one data point: arm64 macOS (8 logical cores), Apple clang, | ||
| Release, C++20. Single runs of `bench/overhead_bench`, which measures each | ||
| path against an untraced twin of the same work and refuses to report if | ||
| the instrumented computation diverges. | ||
|
|
||
| | path | cost per event | workload | | ||
| | --------------------------- | -------------: | --------------------------------- | | ||
| | scope macro (`CYCLESCOPE_SCOPE`) | 136 ns | 1,000,000 scopes vs empty loop | | ||
| | compiler instrumentation | 136 ns | fib(26), 392,835 calls vs plain | | ||
|
|
||
| The two paths cost the same per event, which is expected: both are | ||
| dominated by the shared record path (the timestamp read and the | ||
| uncontended buffer mutex), with the instrumentation backend adding only an | ||
| interned-name hash lookup on top. That | ||
| budget suits functions of microseconds and up; instrumenting a | ||
| few-nanosecond leaf function multiplies its cost by an order of magnitude, | ||
| which is visible in the fib workload itself (traced runtime grows from | ||
| under a millisecond to tens of milliseconds). The practical guidance | ||
| follows directly: instrument coarse-grained code or selected translation | ||
| units, and use the scope macro where call-level granularity is too fine. | ||
|
|
||
| Reproduce with: | ||
|
|
||
| ``` | ||
| cmake -S . -B build -DCMAKE_BUILD_TYPE=Release | ||
| cmake --build build --parallel | ||
| ./build/bench/overhead_bench | ||
| ./build/examples/cyclescope_demo # writes demo.trace.json for Perfetto | ||
| ``` | ||
|
|
||
| ## Limits, stated plainly | ||
|
|
||
| - Names must have static lifetime; dynamic names need interning by the | ||
| caller. | ||
| - pid is fixed at 1; single-process traces are the target. | ||
| - Buffers of exited threads persist until `clear()`; long-running programs | ||
| with thread churn should clear after each flush. | ||
| - Events recorded on a thread after its TLS teardown, or beyond a buffer's | ||
| capacity, are dropped and counted, never silently lost. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Logic error: loop iterates one too many times, returning
2*fib(n+2)-1instead of2*fib(n+1)-1.Starting from
(a=0, b=1) = (fib(0), fib(1)), each iteration advances by one Fibonacci step. Aftern+1iterationsb = fib(n+2), sofib_call_countreturns2*fib(n+2)-1— not the2*fib(n+1)-1stated in the comment (which is the correct total-call formula).Concrete impact for
fib_n=26:T(26) = 2*fib(27)−1 = 392,835fib_call_count(26)returns2*fib(28)−1 = 635,621(~1.618× too high)measure_instrumentationdivides by this inflated count, so the printedns/callis ~39% lower than the true per-call overheaddocs/design.mdreports "635,621 calls" which propagates this error into the published numbersWith
i < nthe loop runsniterations, givingb = fib(n+1)and the correct return value2*fib(n+1)-1.