src/profiler.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file profiler.cpp | ||
| 3 | * @brief Implementation of the lock-free ring buffer profiler with Chrome Tracing export. | ||
| 4 | */ | ||
| 5 | |||
| 6 | #include "DetourModKit/profiler.hpp" | ||
| 7 | |||
| 8 | #include <windows.h> | ||
| 9 | #include <cstdio> | ||
| 10 | #include <cstring> | ||
| 11 | #include <format> | ||
| 12 | #include <memory> | ||
| 13 | #include <new> | ||
| 14 | #include <string> | ||
| 15 | #include <string_view> | ||
| 16 | |||
| 17 | namespace DetourModKit | ||
| 18 | { | ||
| 19 | namespace | ||
| 20 | { | ||
| 21 | /** | ||
| 22 | * @brief Escapes @p input for use in a JSON value, without the outer quotes. | ||
| 23 | * @details Forward slash is left alone. RFC 8259 permits that form. | ||
| 24 | */ | ||
| 25 | 59384 | std::string escape_json_string(std::string_view input) | |
| 26 | { | ||
| 27 | 59384 | std::string out; | |
| 28 |
1/2✓ Branch 4 → 5 taken 59384 times.
✗ Branch 4 → 33 not taken.
|
59384 | out.reserve(input.size()); |
| 29 |
2/2✓ Branch 29 → 7 taken 994308 times.
✓ Branch 29 → 30 taken 59384 times.
|
1053692 | for (const char c : input) |
| 30 | { | ||
| 31 |
8/8✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 10 taken 1 time.
✓ Branch 7 → 12 taken 1 time.
✓ Branch 7 → 14 taken 1 time.
✓ Branch 7 → 16 taken 1 time.
✓ Branch 7 → 18 taken 1 time.
✓ Branch 7 → 20 taken 1 time.
✓ Branch 7 → 22 taken 994301 times.
|
994308 | switch (c) |
| 32 | { | ||
| 33 | 1 | case '"': | |
| 34 |
1/2✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 33 not taken.
|
1 | out += "\\\""; |
| 35 | 1 | break; | |
| 36 | 1 | case '\\': | |
| 37 |
1/2✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 33 not taken.
|
1 | out += "\\\\"; |
| 38 | 1 | break; | |
| 39 | 1 | case '\b': | |
| 40 |
1/2✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 33 not taken.
|
1 | out += "\\b"; |
| 41 | 1 | break; | |
| 42 | 1 | case '\f': | |
| 43 |
1/2✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 33 not taken.
|
1 | out += "\\f"; |
| 44 | 1 | break; | |
| 45 | 1 | case '\n': | |
| 46 |
1/2✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 33 not taken.
|
1 | out += "\\n"; |
| 47 | 1 | break; | |
| 48 | 1 | case '\r': | |
| 49 |
1/2✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 33 not taken.
|
1 | out += "\\r"; |
| 50 | 1 | break; | |
| 51 | 1 | case '\t': | |
| 52 |
1/2✓ Branch 20 → 21 taken 1 time.
✗ Branch 20 → 33 not taken.
|
1 | out += "\\t"; |
| 53 | 1 | break; | |
| 54 | 994301 | default: | |
| 55 |
2/2✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 26 taken 994300 times.
|
994301 | if (static_cast<unsigned char>(c) < 0x20) |
| 56 | { | ||
| 57 | char buf[8]; | ||
| 58 | 1 | std::snprintf( | |
| 59 | buf, | ||
| 60 | sizeof(buf), | ||
| 61 | "\\u%04x", | ||
| 62 |
1/2✓ Branch 23 → 24 taken 1 time.
✗ Branch 23 → 32 not taken.
|
1 | static_cast<unsigned int>(static_cast<unsigned char>(c)) |
| 63 | ); | ||
| 64 |
1/2✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 32 not taken.
|
1 | out += buf; |
| 65 | } | ||
| 66 | else | ||
| 67 | { | ||
| 68 |
1/2✓ Branch 26 → 27 taken 994300 times.
✗ Branch 26 → 33 not taken.
|
994300 | out += c; |
| 69 | } | ||
| 70 | 994301 | break; | |
| 71 | } | ||
| 72 | } | ||
| 73 | 59384 | return out; | |
| 74 | ✗ | } | |
| 75 | } // namespace | ||
| 76 | |||
| 77 | 41 | Profiler::Profiler() noexcept : m_ring(DEFAULT_CAPACITY) | |
| 78 | { | ||
| 79 | LARGE_INTEGER freq; | ||
| 80 | // QueryPerformanceFrequency cannot fail and is always non-zero on Windows XP and later, but guard regardless: a | ||
| 81 | // zero frequency would make every conversion degenerate. Fall back to a 10 MHz tick so durations stay finite if | ||
| 82 | // the API ever misbehaves. | ||
| 83 |
3/6✓ Branch 4 → 5 taken 41 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 41 times.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 41 times.
✗ Branch 8 → 10 not taken.
|
41 | if (QueryPerformanceFrequency(&freq) && freq.QuadPart > 0) |
| 84 | { | ||
| 85 | 41 | m_qpc_frequency = freq.QuadPart; | |
| 86 | } | ||
| 87 | else | ||
| 88 | { | ||
| 89 | ✗ | m_qpc_frequency = 10'000'000; | |
| 90 | } | ||
| 91 | 41 | } | |
| 92 | |||
| 93 | 1833 | Profiler &Profiler::get_instance() noexcept | |
| 94 | { | ||
| 95 | // Constructed once into function-local static storage and never destroyed, mirroring StringPool::instance(). | ||
| 96 | // A Meyers singleton (`static Profiler instance;`) registers a static destructor that frees the ring at | ||
| 97 | // static-teardown time. A ScopedProfile whose own destructor runs after that (a static or thread_local | ||
| 98 | // ScopedProfile, or one on a thread still alive at process teardown) then records through this accessor | ||
| 99 | // into freed storage. Placement-new into raw static storage keeps the object alive for the whole process; the | ||
| 100 | // single fixed-size ring is reclaimed by the OS at exit. | ||
| 101 | // | ||
| 102 | // Construction cannot throw: the ring allocates with nothrow new and reports capacity 0 if that fails, so the | ||
| 103 | // worst first-use outcome is a disabled profiler rather than a terminate out of this noexcept accessor. | ||
| 104 | alignas(Profiler) static unsigned char storage[sizeof(Profiler)]; | ||
| 105 |
5/6✓ Branch 2 → 3 taken 44 times.
✓ Branch 2 → 10 taken 1789 times.
✓ Branch 4 → 5 taken 41 times.
✓ Branch 4 → 10 taken 3 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 41 times.
|
1833 | static Profiler *const instance = ::new (static_cast<void *>(storage)) Profiler(); |
| 106 | 1840 | return *instance; | |
| 107 | } | ||
| 108 | |||
| 109 | 389049 | void Profiler::record(const char *name, int64_t start_ticks, int64_t end_ticks, uint32_t thread_id) noexcept | |
| 110 | { | ||
| 111 |
1/2✓ Branch 2 → 3 taken 392702 times.
✗ Branch 2 → 4 not taken.
|
389049 | record(name, name != nullptr ? std::strlen(name) : 0, start_ticks, end_ticks, thread_id); |
| 112 | 392189 | } | |
| 113 | |||
| 114 | 387428 | void Profiler::record( | |
| 115 | const char *name, | ||
| 116 | size_t name_length, | ||
| 117 | int64_t start_ticks, | ||
| 118 | int64_t end_ticks, | ||
| 119 | uint32_t thread_id | ||
| 120 | ) noexcept | ||
| 121 | { | ||
| 122 | 387428 | const auto claim = m_ring.claim(); | |
| 123 |
2/2✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 5 taken 393281 times.
|
393286 | if (!claim.owned) |
| 124 | { | ||
| 125 | 5 | return; | |
| 126 | } | ||
| 127 | 393281 | const size_t bounded_length = name_length < UINT32_MAX ? name_length : UINT32_MAX; | |
| 128 | 393281 | m_ring.publish( | |
| 129 | claim, | ||
| 130 | name, | ||
| 131 | static_cast<uint32_t>(bounded_length), | ||
| 132 | start_ticks, | ||
| 133 | detail::ticks_to_microseconds(start_ticks, end_ticks, m_qpc_frequency), | ||
| 134 | thread_id | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | // Caller must ensure no concurrent record() calls are in flight. There is no runtime guard because an atomic | ||
| 139 | // "recording active" counter would penalize every record() call on the hot path for a contract that only matters at | ||
| 140 | // session boundaries. | ||
| 141 | 71 | void Profiler::reset() noexcept | |
| 142 | { | ||
| 143 | 71 | m_ring.reset(); | |
| 144 | 71 | } | |
| 145 | |||
| 146 | 30 | std::string Profiler::export_chrome_json() const | |
| 147 | { | ||
| 148 | 30 | std::string json; | |
| 149 | 30 | const auto resident = static_cast<size_t>(m_ring.resident()); | |
| 150 |
2/2✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 10 taken 24 times.
|
30 | if (resident == 0) |
| 151 | { | ||
| 152 |
1/2✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 24 not taken.
|
12 | return "[]"; |
| 153 | } | ||
| 154 | |||
| 155 | // ~120 bytes per JSON event is a reasonable estimate. | ||
| 156 |
1/2✓ Branch 10 → 11 taken 24 times.
✗ Branch 10 → 31 not taken.
|
24 | json.reserve(resident * 120 + 4); |
| 157 |
1/2✓ Branch 11 → 12 taken 24 times.
✗ Branch 11 → 31 not taken.
|
24 | json += "[\n"; |
| 158 | |||
| 159 | 24 | const double ticks_to_us = 1'000'000.0 / static_cast<double>(m_qpc_frequency); | |
| 160 | 24 | bool first = true; | |
| 161 | 24 | m_ring.visit_committed( | |
| 162 |
1/2✓ Branch 12 → 13 taken 24 times.
✗ Branch 12 → 27 not taken.
|
24 | [&](const char *name, uint32_t name_length, int64_t start_ticks, uint32_t duration_us, uint32_t thread_id) |
| 163 | { | ||
| 164 |
2/2✓ Branch 2 → 3 taken 59360 times.
✓ Branch 2 → 4 taken 24 times.
|
59384 | if (!first) |
| 165 | { | ||
| 166 |
1/2✓ Branch 3 → 4 taken 59360 times.
✗ Branch 3 → 19 not taken.
|
59360 | json += ",\n"; |
| 167 | } | ||
| 168 | 59384 | first = false; | |
| 169 | |||
| 170 | // Chrome Trace Event Format: "X" = complete event (has duration). The name is escaped so a caller's | ||
| 171 | // quotes or backslashes still produce valid JSON. The published extent bounds the read, so a source | ||
| 172 | // array with no terminator cannot cause an over-read. | ||
| 173 | 59384 | const double ts = static_cast<double>(start_ticks) * ticks_to_us; | |
| 174 |
1/2✓ Branch 6 → 7 taken 59384 times.
✗ Branch 6 → 13 not taken.
|
118768 | json += std::format( |
| 175 | R"({{"name":"{}","ph":"X","ts":{:.1f},"dur":{},"pid":1,"tid":{}}})", | ||
| 176 |
1/2✓ Branch 5 → 6 taken 59384 times.
✗ Branch 5 → 16 not taken.
|
118768 | escape_json_string(std::string_view{name, name_length}), |
| 177 | ts, | ||
| 178 | duration_us, | ||
| 179 | thread_id | ||
| 180 |
1/2✓ Branch 7 → 8 taken 59384 times.
✗ Branch 7 → 11 not taken.
|
59384 | ); |
| 181 | 59384 | } | |
| 182 | ); | ||
| 183 | |||
| 184 |
1/2✗ Branch 13 → 14 not taken.
✓ Branch 13 → 19 taken 24 times.
|
24 | if (first) |
| 185 | { | ||
| 186 | ✗ | return "[]"; | |
| 187 | } | ||
| 188 |
1/2✓ Branch 19 → 20 taken 24 times.
✗ Branch 19 → 31 not taken.
|
24 | json += "\n]"; |
| 189 | 24 | return json; | |
| 190 | 30 | } | |
| 191 | |||
| 192 | 5 | bool Profiler::export_to_file(std::string_view path) const | |
| 193 | { | ||
| 194 |
1/2✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 43 not taken.
|
5 | const std::string json = export_chrome_json(); |
| 195 |
1/2✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 34 not taken.
|
5 | const std::string path_str(path); |
| 196 | |||
| 197 | ✗ | const auto closer = [](std::FILE *f) { std::fclose(f); }; | |
| 198 | 5 | std::FILE *file_ptr = nullptr; | |
| 199 | |||
| 200 |
1/2✓ Branch 8 → 9 taken 5 times.
✗ Branch 8 → 39 not taken.
|
5 | const errno_t err = fopen_s(&file_ptr, path_str.c_str(), "wb"); |
| 201 |
3/4✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 11 taken 1 time.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 4 times.
|
5 | if (err != 0 || file_ptr == nullptr) |
| 202 | { | ||
| 203 | 1 | return false; | |
| 204 | } | ||
| 205 | |||
| 206 | 4 | std::unique_ptr<std::FILE, decltype(closer)> fp(file_ptr, closer); | |
| 207 |
1/2✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 37 not taken.
|
4 | const size_t written = std::fwrite(json.data(), 1, json.size(), fp.get()); |
| 208 |
1/2✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 4 times.
|
4 | if (written != json.size()) |
| 209 | { | ||
| 210 | ✗ | return false; | |
| 211 | } | ||
| 212 |
2/4✓ Branch 21 → 22 taken 4 times.
✗ Branch 21 → 37 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 4 times.
|
4 | if (std::fflush(fp.get()) != 0) |
| 213 | { | ||
| 214 | ✗ | return false; | |
| 215 | } | ||
| 216 | // Release the pointer so unique_ptr does not double-close. | ||
| 217 |
2/4✓ Branch 25 → 26 taken 4 times.
✗ Branch 25 → 37 not taken.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 4 times.
|
4 | if (std::fclose(fp.release()) != 0) |
| 218 | { | ||
| 219 | ✗ | return false; | |
| 220 | } | ||
| 221 | 4 | return true; | |
| 222 | 5 | } | |
| 223 | |||
| 224 | 38 | size_t Profiler::total_samples_recorded() const noexcept | |
| 225 | { | ||
| 226 | 38 | return static_cast<size_t>(m_ring.claims()); | |
| 227 | } | ||
| 228 | |||
| 229 | 13 | size_t Profiler::available_samples() const noexcept | |
| 230 | { | ||
| 231 | 13 | return static_cast<size_t>(m_ring.resident()); | |
| 232 | } | ||
| 233 | |||
| 234 | 6 | size_t Profiler::dropped_samples() const noexcept | |
| 235 | { | ||
| 236 | 6 | return static_cast<size_t>(m_ring.dropped()); | |
| 237 | } | ||
| 238 | |||
| 239 | 12 | size_t Profiler::capacity() const noexcept | |
| 240 | { | ||
| 241 | 12 | return m_ring.capacity(); | |
| 242 | } | ||
| 243 | |||
| 244 | 5 | int64_t Profiler::qpc_frequency() const noexcept | |
| 245 | { | ||
| 246 | 5 | return m_qpc_frequency; | |
| 247 | } | ||
| 248 | |||
| 249 | 1629 | ScopedProfile::ScopedProfile(const char *name, size_t name_length, LiteralTag) noexcept | |
| 250 | 1629 | : m_name(name), m_name_length(name_length), m_thread_id(GetCurrentThreadId()) | |
| 251 | { | ||
| 252 | LARGE_INTEGER ticks; | ||
| 253 | 1617 | QueryPerformanceCounter(&ticks); | |
| 254 | 1698 | m_start_ticks = ticks.QuadPart; | |
| 255 | 1698 | } | |
| 256 | |||
| 257 | 1661 | ScopedProfile::~ScopedProfile() noexcept | |
| 258 | { | ||
| 259 | LARGE_INTEGER ticks; | ||
| 260 | 1661 | QueryPerformanceCounter(&ticks); | |
| 261 | 1725 | Profiler::get_instance().record(m_name, m_name_length, m_start_ticks, ticks.QuadPart, m_thread_id); | |
| 262 | 1653 | } | |
| 263 | |||
| 264 | } // namespace DetourModKit | ||
| 265 |