A tiny, zero-intrusion C++17 RAII scope timer for high-performance code paths. Drop a single macro into a scope and get a structured log line on exit with start, end, and elapsed time. In release builds it compiles to a no-op; in debug builds it's lock-light and allocation-free on the hot path.
Development note: this codebase has been developed primarily on macOS, with support for Linux and Windows in the implementation. Linux is exercised in GitHub Actions. Windows support has not been fully tested under Windows.
The concept for this class & macro originated from a software project I worked on in the early 1990's while at Merril Lynch. It owed a lot to James O. Coplien's Advanced C++: Programming Styles and Idioms (Addison-Wesley, first ed. 1991). A very smart guy that I had the pleaseure to work with when I was at Bloomberg in 2016. This version is a re-imagining of the original. Rewritten from scratch for C++17/20.
☕ If you found this project useful, consider buying me a coffee or dropping a comment — it keeps the caffeine and ideas flowing! 😄
- Fast, frictionless profiling of functions, blocks, and object lifetimes.
- Production-safe toggling: enabled in Debug, compiled out in Release (via
NDEBUG). - Consistent, parseable output to an append-only log file or custom sink for ad-hoc analysis.
- Zero friction: a macro expands to a short-lived RAII object; no manual start/stop.
- Hot-path friendly: elapsed-unit selection is a function pointer chosen
once from
SCOPE_TIMER_FORMAT; per-use formatting uses fixed buffers and manual assembly rather than repeated environment parsing orsnprintf. - Predictable output: timestamped start/end + elapsed, suitable for grepping or ingestion.
- Safe by default: thread-safe direct file appends with periodic flush, plus optional buffered and async sink modes when you need lower caller-thread overhead.
- Portable: uses
localtime_s(Windows) orlocaltime_r(POSIX). - Release-friendly: expands to no-ops under
NDEBUGso you can leave calls in code.
- RAII timing: starts on construction, logs on destruction.
- Unique per-use macro: safe to place multiple timers in the same scope.
- Thread-safe logging with periodic flush plus optional thread-buffered and async sink modes.
- One-time-selected formatter (functor) for elapsed units via
SCOPE_TIMER_FORMAT. No per-call branching. - Pluggable log sink via a small public
ScopeTimer::LogSinkinterface. - Portable time formatting (uses
localtime_s/localtime_r).
Minimal ScopedTimer examples from books such as C++ High Performance
usually show the core RAII idiom only: capture a start time in the
constructor and print elapsed milliseconds in the destructor. Boost's
auto_cpu_timer is a more polished library utility that automatically
reports wall, user, and system CPU time when a scope ends.
ScopeTimer is aimed at a broader use case. It keeps the same RAII model,
but adds macro-based insertion, Release-build no-ops, unique macro
expansion, optional labels, conditional and hot-path timers, cached
environment-driven configuration, thread IDs, formatted start/end
timestamps, multiple sink backends, and test hooks.
In short, this project is less a single timer helper and more a lightweight instrumentation and logging subsystem built around the scope-timer idiom.
SCOPE_TIMER(...) expands to a short-lived stack variable whose name must be
unique per expansion. I generate that name with a two-stage token-pasting
helper:
#define ST_CAT2(a,b) a##b
#define ST_CAT(a,b) ST_CAT2(a,b)Then I suffix the variable with __COUNTER__ when available, which is a
translation-unit-global monotonically increasing integer, ensuring global
uniqueness even if multiple timers are emitted on the same source line
(e.g., from another macro). If a compiler doesn't support __COUNTER__,
I fall back to __LINE__, which is unique per line but can collide
if multiple expansions end up on the same line. The two-stage concat ensures
the macro arguments are expanded before token pasting.
SCOPE_TIMER- Controls whether timing/logging is enabled in Debug builds. Set to"OFF","FALSE","NO", or"0"(case-insensitive; surrounding whitespace ignored) to disable logging entirely. Any other value, or if unset, leaves logging enabled. In Release builds (NDEBUGdefined) this variable has no effect becauseSCOPE_TIMERcalls compile to no-ops.SCOPE_TIMER_DIR- Directory forScopeTimer.log(default/tmp).SCOPE_TIMER_FLUSH_N- Flush every N lines (default 4096, max 1,000,000).SCOPE_TIMER_FORMAT- Elapsed units:SECONDS,MILLIS,MICROS, orNANOS(case-insensitive). If unset/invalid, auto-selects a readable unit.SCOPE_TIMER_WALLTIME- Set to"OFF","FALSE","NO", or"0"to omitstart=andend=timestamps from each log line and reduce timer overhead.
#include "ScopeTimer.hpp"
void foo() {
SCOPE_TIMER("foo"); // Logs when foo() exits
// ... work ...
}You do not need to copy this repo's full CMakeLists.txt into your own
project. The repo CMake drives the demo app, benchmark app, tests, coverage,
Sonar, and doc generation. For normal use, ScopeTimer is just a header-only
dependency.
- Copy
include/ScopeTimer.hppinto your project, or vendor this repo under something likethird_party/ScopeTimer. - Add the header directory to your target's include path.
- Compile your target as C++17 or newer.
- Include
ScopeTimer.hppwherever you want to time a scope. - Run your app with
SCOPE_TIMER=1in debug builds if you want logging explicitly enabled.
There is no library to link and no install step required for basic use.
If you vendor this repo at third_party/ScopeTimer, a consuming target can be
as simple as:
cmake_minimum_required(VERSION 3.16)
project(MyApp LANGUAGES CXX)
add_executable(my_app
src/main.cpp
)
target_compile_features(my_app PRIVATE cxx_std_17)
target_include_directories(my_app PRIVATE
${CMAKE_SOURCE_DIR}/third_party/ScopeTimer/include
)Then in your code:
#include "ScopeTimer.hpp"
int main() {
SCOPE_TIMER("main");
// app code
}g++ -std=c++17 -I./third_party/ScopeTimer/include src/main.cpp -o my_app- In builds where
NDEBUGis defined,SCOPE_TIMER(...)expands to a no-op. That means a typical Release build will compile the timers out completely. - In builds where
NDEBUGis not defined, the timers are active and can be controlled with environment variables such asSCOPE_TIMER,SCOPE_TIMER_DIR,SCOPE_TIMER_FORMAT, andSCOPE_TIMER_WALLTIME. - If you want to try the lower-overhead sink modes in your own app, use the
public macros directly:
SCOPE_TIMER_ENABLE_THREAD_BUFFERED_SINK(...),SCOPE_TIMER_DISABLE_THREAD_BUFFERED_SINK(),SCOPE_TIMER_ENABLE_ASYNC_SINK(...),SCOPE_TIMER_DISABLE_ASYNC_SINK(),SCOPE_TIMER_HOT_PATH(...), andScopeTimer::setLogSink(...)/ScopeTimer::resetLogSink().
void bar(bool enabled) {
SCOPE_TIMER_IF(enabled, "bar"); // Only logs when enabled == true
// ... work ...
}void hotPath() {
SCOPE_TIMER_ENABLE_THREAD_BUFFERED_SINK(64 * 1024);
SCOPE_TIMER("hotPath");
// ... work ...
SCOPE_TIMER_DISABLE_THREAD_BUFFERED_SINK();
}Use the thread-buffered sink when mutex contention on the default logger shows up in profiling. Buffered entries flush when the per-thread buffer reaches the configured threshold, when you disable the buffered sink, when a worker thread exits, and during process shutdown. Threshold handoffs publish the completed batch immediately, but defer the expensive final sink flush until an explicit flush or teardown point. Enable or disable it around the setup or teardown of the code path you are profiling, after any worker threads using it have quiesced. Switching sink mode is synchronized, but it is still a configuration step rather than something to toggle for every individual timer.
void fanOut() {
SCOPE_TIMER_ENABLE_ASYNC_SINK(64 * 1024);
SCOPE_TIMER("fanOut");
// ... work ...
SCOPE_TIMER_DISABLE_ASYNC_SINK();
}Use the async sink when the buffered sink still spends too much time flushing on
the caller thread. Async mode keeps the cheap thread-local buffering path, then
hands full buffers to a background writer thread. Larger handoff sizes such as
64 * 1024 reduce queue churn when you care more about throughput than
tail-latency of the final write.
#include <iostream>
#include "ScopeTimer.hpp"
class CoutLogSink final : public ::xyzzy::scopetimer::ScopeTimer::LogSink {
public:
void write(const char* data, std::size_t len) noexcept override {
std::cout.write(data, static_cast<std::streamsize>(len));
}
void flush() noexcept override {
std::cout.flush();
}
};
void emitToStdout() {
CoutLogSink sink;
::xyzzy::scopetimer::ScopeTimer::setLogSink(sink);
SCOPE_TIMER("emitToStdout");
// ... work ...
::xyzzy::scopetimer::ScopeTimer::resetLogSink();
}Use a custom sink when you want ScopeTimer to write to an existing logging path instead of the default logfile. The sink object must outlive the registration. With a custom sink registered, direct timers write to it immediately, and the built-in buffered and async modes use it as their final output target too. A no-op implementation is also useful when you want to benchmark ScopeTimer's own overhead without measuring output I/O.
void ingestRecord() {
SCOPE_TIMER_HOT_PATH("ingestRecord");
// ... very busy code ...
}SCOPE_TIMER_HOT_PATH is the minimal-overhead option for extremely hot code.
It skips function signatures, thread ids, and wall-clock timestamps, and logs a
compact elapsed=<n>ns line for the supplied label.
The benchmark profiles in BENCHMARK.md are not separate
library modes that you enable by profile name. They are just shorthand for
combinations of the public macros and runtime settings shown below.
Do not use SCOPE_TIMER_BENCH_* environment variables in your own app. Those
exist only so example/Benchmark.cpp can switch benchmark profiles without
editing code.
-
Standard timer, default sinkUse plain
SCOPE_TIMER(...).void handleRequest() { SCOPE_TIMER("handleRequest"); // work }
-
Standard timer, wall time disabledKeep
SCOPE_TIMER(...)in code, but start the process with wall time formatting disabled.SCOPE_TIMER_WALLTIME=0 ./my_app
void handleRequest() { SCOPE_TIMER("handleRequest"); // work }
-
Standard timer, buffered sinkEnable the thread-buffered sink around the profiled phase, then keep using
SCOPE_TIMER(...).int main() { SCOPE_TIMER_ENABLE_THREAD_BUFFERED_SINK(64 * 1024); runServer(); SCOPE_TIMER_DISABLE_THREAD_BUFFERED_SINK(); }
-
Standard timer, buffered sink (threaded stress)This is the same public API as buffered sink. The benchmark name just means the workload is multi-threaded while buffered sink is enabled.
void workerLoop() { SCOPE_TIMER("workerLoop"); // threaded work }
-
Standard timer, async sinkEnable async sink around the profiled phase, then keep using
SCOPE_TIMER(...).int main() { SCOPE_TIMER_ENABLE_ASYNC_SINK(64 * 1024); runServer(); SCOPE_TIMER_DISABLE_ASYNC_SINK(); }
-
Standard timer, null sinkRegister a no-op custom sink, then keep using
SCOPE_TIMER(...).struct NullSink final : ::xyzzy::scopetimer::ScopeTimer::LogSink { void write(const char*, std::size_t) noexcept override {} }; int main() { NullSink sink; ::xyzzy::scopetimer::ScopeTimer::setLogSink(sink); runServer(); ::xyzzy::scopetimer::ScopeTimer::resetLogSink(); }
-
Hot-path timer, async sinkEnable async sink, but switch the hottest code to
SCOPE_TIMER_HOT_PATH(...)instead ofSCOPE_TIMER(...).int main() { SCOPE_TIMER_ENABLE_ASYNC_SINK(64 * 1024); runIngestion(); SCOPE_TIMER_DISABLE_ASYNC_SINK(); } void ingestRecord() { SCOPE_TIMER_HOT_PATH("ingestRecord"); // very busy code }
-
Hot-path timer, null sinkCombine the no-op sink with
SCOPE_TIMER_HOT_PATH(...)to measure the framework floor without output I/O.struct NullSink final : ::xyzzy::scopetimer::ScopeTimer::LogSink { void write(const char*, std::size_t) noexcept override {} }; int main() { NullSink sink; ::xyzzy::scopetimer::ScopeTimer::setLogSink(sink); runIngestion(); ::xyzzy::scopetimer::ScopeTimer::resetLogSink(); } void ingestRecord() { SCOPE_TIMER_HOT_PATH("ingestRecord"); // very busy code }
void baz() {
SCOPE_TIMER("baz:first");
// ... work A ...
SCOPE_TIMER("baz:second");
// ... work B ...
}void nested() {
SCOPE_TIMER("nested:outer");
{
SCOPE_TIMER("nested:inner");
// ... inner work ...
}
}class LifetimeTracked {
public:
LifetimeTracked() : lifetimeTimer_("LifetimeTracked") { /* setup */ }
~LifetimeTracked() { /* teardown */ }
private:
::xyzzy::scopetimer::ScopeTimer lifetimeTimer_;
};Build, coverage, Sonar, and benchmark-target usage now live in BUILD.md.
Leak checking is platform-specific in this repo: leak_check uses
macOS leaks for local runs on the MacBook and Valgrind for Linux runs
in GitHub Actions. Benchmarks are kept local-only, so performance runs
should be done on the MacBook before pushing rather than in GitHub
Actions.
Elapsed-format examples and the log-summary pipeline now live in TESTS.md.
Current benchmark results, profile guidance, and reproducible per-profile commands now live in BENCHMARK.md.
© 2025 Steve Clarke · https://xyzzy.tools · Released under the AGPL-3.0 License