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