GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 94.1% 128 / 0 / 136
Functions: 92.9% 13 / 0 / 14
Branches: 62.7% 64 / 0 / 102

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 <algorithm>
10 #include <cstdio>
11 #include <format>
12 #include <memory>
13 #include <string>
14 #include <string_view>
15
16 namespace DetourModKit
17 {
18 namespace
19 {
20 /**
21 * @brief Escapes a string for safe embedding in a JSON value.
22 * @details Handles the characters that are special in JSON strings:
23 * backslash, double quote, and control characters (U+0000..U+001F). Forward slash is NOT escaped
24 * (legal unescaped in JSON per RFC 8259).
25 * @param input The raw string to escape.
26 * @return A JSON-safe escaped string (without surrounding quotes).
27 */
28 65548 std::string escape_json_string(std::string_view input)
29 {
30 65548 std::string out;
31
1/2
✓ Branch 4 → 5 taken 65548 times.
✗ Branch 4 → 33 not taken.
65548 out.reserve(input.size());
32
2/2
✓ Branch 29 → 7 taken 1114233 times.
✓ Branch 29 → 30 taken 65548 times.
1179781 for (const char c : input)
33 {
34
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 1114226 times.
1114233 switch (c)
35 {
36 1 case '"':
37
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 33 not taken.
1 out += "\\\"";
38 1 break;
39 1 case '\\':
40
1/2
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 33 not taken.
1 out += "\\\\";
41 1 break;
42 1 case '\b':
43
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 33 not taken.
1 out += "\\b";
44 1 break;
45 1 case '\f':
46
1/2
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 33 not taken.
1 out += "\\f";
47 1 break;
48 1 case '\n':
49
1/2
✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 33 not taken.
1 out += "\\n";
50 1 break;
51 1 case '\r':
52
1/2
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 33 not taken.
1 out += "\\r";
53 1 break;
54 1 case '\t':
55
1/2
✓ Branch 20 → 21 taken 1 time.
✗ Branch 20 → 33 not taken.
1 out += "\\t";
56 1 break;
57 1114226 default:
58
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 26 taken 1114225 times.
1114226 if (static_cast<unsigned char>(c) < 0x20)
59 {
60 // Control characters U+0000..U+001F require \uXXXX encoding
61 char buf[8];
62 1 std::snprintf(buf, sizeof(buf), "\\u%04x",
63
1/2
✓ Branch 23 → 24 taken 1 time.
✗ Branch 23 → 32 not taken.
1 static_cast<unsigned int>(static_cast<unsigned char>(c)));
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 1114225 times.
✗ Branch 26 → 33 not taken.
1114225 out += c;
69 }
70 1114226 break;
71 }
72 }
73 65548 return out;
74 }
75 } // anonymous namespace
76
77 // --- Profiler ---
78
79 28 Profiler::Profiler()
80 28 : m_buffer(std::make_unique<ProfileSample[]>(DEFAULT_CAPACITY)), m_capacity(DEFAULT_CAPACITY),
81 28 m_mask(DEFAULT_CAPACITY - 1)
82 {
83 LARGE_INTEGER freq;
84 // QueryPerformanceFrequency cannot fail and is always non-zero on Windows XP and later, but guard regardless: a
85 // zero frequency would divide-by-zero in record(). Fall back to a 10 MHz tick so durations stay finite if the
86 // API ever misbehaves.
87
4/8
✓ Branch 4 → 5 taken 28 times.
✗ Branch 4 → 13 not taken.
✓ Branch 5 → 6 taken 28 times.
✗ Branch 5 → 8 not taken.
✓ Branch 6 → 7 taken 28 times.
✗ Branch 6 → 8 not taken.
✓ Branch 9 → 10 taken 28 times.
✗ Branch 9 → 11 not taken.
28 if (QueryPerformanceFrequency(&freq) && freq.QuadPart > 0)
88 {
89 28 m_qpc_frequency = freq.QuadPart;
90 }
91 else
92 {
93 m_qpc_frequency = 10'000'000;
94 }
95 28 }
96
97 1848 Profiler &Profiler::get_instance() noexcept
98 {
99
3/4
✓ Branch 2 → 3 taken 28 times.
✓ Branch 2 → 8 taken 1820 times.
✓ Branch 4 → 5 taken 28 times.
✗ Branch 4 → 8 not taken.
1848 static Profiler instance;
100 1868 return instance;
101 }
102
103 613611 void Profiler::record(const char *name, int64_t start_ticks, int64_t end_ticks, uint32_t thread_id) noexcept
104 {
105 // Clamp a non-positive delta (end before start: swapped arguments or a backwards clock read) to zero so the
106 // unsigned cast below cannot wrap a negative value into a bogus multi-minute duration.
107
1/2
✓ Branch 2 → 3 taken 619048 times.
✗ Branch 2 → 4 not taken.
613611 const int64_t delta_ticks = end_ticks > start_ticks ? end_ticks - start_ticks : 0;
108
109 // Convert ticks to microseconds: (delta * 1'000'000) / frequency. The 64-bit intermediate (delta * 1'000'000)
110 // cannot overflow for any realistic
111 // delta: at a 10 MHz QPC frequency it would take a delta over ~922,000 seconds.
112 // duration_us is additionally clamped to UINT32_MAX microseconds (~71 minutes).
113 const auto duration_us = static_cast<uint32_t>(
114 613611 std::min<int64_t>((delta_ticks * 1'000'000) / m_qpc_frequency, static_cast<int64_t>(UINT32_MAX)));
115
116 607043 const size_t idx = m_write_pos.fetch_add(1, std::memory_order_relaxed) & m_mask;
117
118 607043 auto &sample = m_buffer[idx];
119
120 // Open the write window with a monotonic increment. The result is guaranteed odd because every closed sequence
121 // is even (sequence starts at 0 in the constructor and reset(), and each record() contributes exactly +2).
122 // Using fetch_add avoids the load-then-store RMW pattern: a producer preempted between a relaxed load and its
123 // first store could otherwise roll the slot's sequence backwards if another producer completed a full write on
124 // the same slot in the interim. fetch_add forbids that rollback.
125 //
126 // Design note: if a writer is stalled between its fetch_add and its final sequence store, and 65536 intervening
127 // record() calls advance m_write_pos past a full buffer wrap, a new writer will land on the same slot and
128 // clobber the stalled writer's data. This requires the stalled writer to be preempted for the duration of an
129 // entire ring buffer cycle, which is unreachable at game-modding thread counts and frame rates. We accept this
130 // theoretical imprecision to keep the hot path to a single fetch_add + two stores with no CAS retry loop.
131 //
132 // Monotonicity is unconditionally guaranteed by fetch_add: per
133 // [atomics.types.operations] the counter cannot roll backwards regardless of how many producers race on the
134 // same slot. Do NOT replace this with a load-then-store RMW: that would re-introduce the stale-publish race on
135 // wrap collision that this protocol exists to prevent.
136 static_assert(std::atomic<uint32_t>::is_always_lock_free,
137 "sequence counter must be lock-free for the seqlock protocol");
138 535179 (void)sample.sequence.fetch_add(1, std::memory_order_acq_rel);
139
140 535179 sample.name = name;
141 535179 sample.start_ticks = start_ticks;
142 535179 sample.duration_us = duration_us;
143 535179 sample.thread_id = thread_id;
144
145 // Close the write window. Another +1 keeps the slot's sequence monotonic and lands it on an even value,
146 // signalling a fully committed sample. Readers that observe an odd value skip this slot to avoid reading torn
147 // fields.
148 535179 (void)sample.sequence.fetch_add(1, std::memory_order_release);
149 535179 }
150
151 // Caller must ensure no concurrent record() calls are in flight. There is no runtime guard because adding an atomic
152 // "recording active" counter would penalize every record() call on the hot path for a contract that is only
153 // relevant during session boundaries.
154 51 void Profiler::reset() noexcept
155 {
156 51 m_write_pos.store(0, std::memory_order_relaxed);
157
2/2
✓ Branch 21 → 11 taken 3342336 times.
✓ Branch 21 → 22 taken 51 times.
3342387 for (size_t i = 0; i < m_capacity; ++i)
158 {
159 3342336 auto &sample = m_buffer[i];
160 3342336 sample.sequence.store(0, std::memory_order_relaxed);
161 3342336 sample.name = nullptr;
162 3342336 sample.start_ticks = 0;
163 3342336 sample.duration_us = 0;
164 3342336 sample.thread_id = 0;
165 }
166 51 }
167
168 14 std::string Profiler::export_chrome_json() const
169 {
170 14 const size_t total = m_write_pos.load(std::memory_order_relaxed);
171 14 const size_t count = std::min(total, m_capacity);
172
173
2/2
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 16 taken 12 times.
14 if (count == 0)
174 {
175
1/2
✓ Branch 13 → 14 taken 2 times.
✗ Branch 13 → 61 not taken.
4 return "[]";
176 }
177
178 // Determine start index: if the buffer has wrapped, start from the oldest surviving sample; otherwise start
179 // from 0.
180
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 11 times.
12 const size_t start_idx = (total > m_capacity) ? (total & m_mask) : 0;
181
182 // Pre-allocate: ~120 bytes per JSON event is a reasonable estimate.
183 12 std::string json;
184
1/2
✓ Branch 20 → 21 taken 12 times.
✗ Branch 20 → 72 not taken.
12 json.reserve(count * 120 + 4);
185
1/2
✓ Branch 21 → 22 taken 12 times.
✗ Branch 21 → 72 not taken.
12 json += "[\n";
186
187 // QPC frequency for converting start_ticks to microseconds
188 12 const double ticks_to_us = 1'000'000.0 / static_cast<double>(m_qpc_frequency);
189
190 12 bool first = true;
191
2/2
✓ Branch 55 → 23 taken 65548 times.
✓ Branch 55 → 56 taken 12 times.
65560 for (size_t i = 0; i < count; ++i)
192 {
193 65548 const auto &sample = m_buffer[(start_idx + i) & m_mask];
194
195 // Seqlock read: load the sequence, copy the sample fields into locals, then re-load the sequence. record()
196 // opens a write with an odd sequence and closes it with the next even value, so a sample is consistent only
197 // when the pre-read sequence is even (no in-flight write) AND the post-read sequence is unchanged (no write
198 // started and finished mid-copy). The acquire fence between the field copies and the second load stops the
199 // copies from being reordered after it. This runs on the cold export path; the second load costs nothing
200 // measurable and the producer hot path is untouched.
201 65548 const uint32_t seq_before = sample.sequence.load(std::memory_order_acquire);
202
2/4
✓ Branch 31 → 32 taken 65548 times.
✗ Branch 31 → 33 not taken.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 65548 times.
65548 if ((seq_before & 1) != 0 || sample.name == nullptr)
203 {
204 continue;
205 }
206
207 65548 const char *name = sample.name;
208 65548 const auto start_ticks = sample.start_ticks;
209 65548 const auto duration_us = sample.duration_us;
210 65548 const auto thread_id = sample.thread_id;
211
212 std::atomic_thread_fence(std::memory_order_acquire);
213 65548 const uint32_t seq_after = sample.sequence.load(std::memory_order_relaxed);
214
1/2
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 44 taken 65548 times.
65548 if (seq_after != seq_before)
215 {
216 // A producer overwrote this slot mid-copy; drop the torn sample.
217 continue;
218 }
219
220
2/2
✓ Branch 44 → 45 taken 65536 times.
✓ Branch 44 → 46 taken 12 times.
65548 if (!first)
221 {
222
1/2
✓ Branch 45 → 46 taken 65536 times.
✗ Branch 45 → 71 not taken.
65536 json += ",\n";
223 }
224 65548 first = false;
225
226 // Chrome Trace Event Format: "X" = complete event (has duration). Escape the name to produce valid JSON
227 // even if the caller passes a string containing quotes or backslashes.
228 65548 const double ts = static_cast<double>(start_ticks) * ticks_to_us;
229
1/2
✓ Branch 47 → 48 taken 65548 times.
✗ Branch 47 → 64 not taken.
65548 const std::string escaped_name = escape_json_string(name);
230
1/2
✓ Branch 48 → 49 taken 65548 times.
✗ Branch 48 → 67 not taken.
131096 json += std::format(R"({{"name":"{}","ph":"X","ts":{:.1f},"dur":{},"pid":1,"tid":{}}})", escaped_name, ts,
231
1/2
✓ Branch 49 → 50 taken 65548 times.
✗ Branch 49 → 65 not taken.
65548 duration_us, thread_id);
232 65548 }
233
234
1/2
✓ Branch 56 → 57 taken 12 times.
✗ Branch 56 → 72 not taken.
12 json += "\n]";
235 12 return json;
236 12 }
237
238 3 bool Profiler::export_to_file(std::string_view path) const
239 {
240
1/2
✓ Branch 2 → 3 taken 3 times.
✗ Branch 2 → 43 not taken.
3 const std::string json = export_chrome_json();
241
1/2
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 34 not taken.
3 const std::string path_str(path);
242
243 const auto closer = [](std::FILE *f) { std::fclose(f); };
244 3 std::FILE *file_ptr = nullptr;
245
246
1/2
✓ Branch 8 → 9 taken 3 times.
✗ Branch 8 → 39 not taken.
3 const errno_t err = fopen_s(&file_ptr, path_str.c_str(), "wb");
247
3/4
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 1 time.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 2 times.
3 if (err != 0 || file_ptr == nullptr)
248 {
249 1 return false;
250 }
251
252 2 std::unique_ptr<std::FILE, decltype(closer)> fp(file_ptr, closer);
253
1/2
✓ Branch 16 → 17 taken 2 times.
✗ Branch 16 → 37 not taken.
2 const size_t written = std::fwrite(json.data(), 1, json.size(), fp.get());
254
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 2 times.
2 if (written != json.size())
255 {
256 return false;
257 }
258
2/4
✓ Branch 21 → 22 taken 2 times.
✗ Branch 21 → 37 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 2 times.
2 if (std::fflush(fp.get()) != 0)
259 {
260 return false;
261 }
262 // Release the pointer so unique_ptr does not double-close.
263
2/4
✓ Branch 25 → 26 taken 2 times.
✗ Branch 25 → 37 not taken.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 2 times.
2 if (std::fclose(fp.release()) != 0)
264 {
265 return false;
266 }
267 2 return true;
268 3 }
269
270 15 size_t Profiler::total_samples_recorded() const noexcept
271 {
272 30 return m_write_pos.load(std::memory_order_relaxed);
273 }
274
275 6 size_t Profiler::available_samples() const noexcept
276 {
277 12 return std::min(m_write_pos.load(std::memory_order_relaxed), m_capacity);
278 }
279
280 4 size_t Profiler::capacity() const noexcept
281 {
282 4 return m_capacity;
283 }
284
285 1 int64_t Profiler::qpc_frequency() const noexcept
286 {
287 1 return m_qpc_frequency;
288 }
289
290 // --- ScopedProfile ---
291
292 1733 ScopedProfile::ScopedProfile(const char *name, literal_tag) noexcept
293 1733 : m_name(name), m_thread_id(GetCurrentThreadId())
294 {
295 LARGE_INTEGER ticks;
296 1725 QueryPerformanceCounter(&ticks);
297 1754 m_start_ticks = ticks.QuadPart;
298 1754 }
299
300 1754 ScopedProfile::~ScopedProfile() noexcept
301 {
302 LARGE_INTEGER ticks;
303 1754 QueryPerformanceCounter(&ticks);
304 1771 Profiler::get_instance().record(m_name, m_start_ticks, ticks.QuadPart, m_thread_id);
305 1818 }
306
307 } // namespace DetourModKit
308