lib/tracy/src/frame.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const pretty_json = @import("pretty").json;
3 const capture_mod = @import("capture.zig");
4 const report = @import("report.zig");
5 const event = @import("event.zig");
6 const record_mod = @import("record.zig");
7
8 pub const schema = "tracy.frames/v0";
9
10 pub const Sort = enum {
11 max,
12 total,
13 mean,
14 count,
15 name,
16
17 pub fn fromName(text: []const u8) ?Sort {
18 if (std.mem.eql(u8, text, "max")) return .max;
19 if (std.mem.eql(u8, text, "total")) return .total;
20 if (std.mem.eql(u8, text, "mean")) return .mean;
21 if (std.mem.eql(u8, text, "count")) return .count;
22 if (std.mem.eql(u8, text, "name")) return .name;
23 return null;
24 }
25
26 fn tag(self: Sort) []const u8 {
27 return switch (self) {
28 .max => "max",
29 .total => "total",
30 .mean => "mean",
31 .count => "count",
32 .name => "name",
33 };
34 }
35 };
36
37 pub const Options = struct {
38 top: usize = 20,
39 sort: Sort = .max,
40 min_frame_ns: u64 = 0,
41 budget_ns: ?u64 = null,
42 set: ?[]const u8 = null,
43 };
44
45 pub const Counters = struct {
46 marks: u64 = 0,
47 frames: u64 = 0,
48 out_of_order_marks: u64 = 0,
49 };
50
51 pub const CaptureIntegrity = capture_mod.Integrity;
52
53 const FrameRecord = struct {
54 index: u64,
55 start_ns: u64,
56 end_ns: u64,
57 duration_ns: u64,
58 thread: u64,
59 };
60
61 const FrameSet = struct {
62 name: []const u8,
63 marks: u64 = 0,
64 frames: std.ArrayListUnmanaged(FrameRecord) = .empty,
65 threads: std.ArrayListUnmanaged(u64) = .empty,
66 first_ns: ?u64 = null,
67 last_ns: ?u64 = null,
68 last_mark_ns: ?u64 = null,
69 last_thread: u64 = 0,
70 total_ns: u64 = 0,
71 min_ns: u64 = std.math.maxInt(u64),
72 max_ns: u64 = 0,
73
74 fn deinit(self: *FrameSet, allocator: std.mem.Allocator) void {
75 self.frames.deinit(allocator);
76 self.threads.deinit(allocator);
77 self.* = undefined;
78 }
79
80 fn meanNs(self: FrameSet) u64 {
81 if (self.frames.items.len == 0) return 0;
82 return self.total_ns / self.frames.items.len;
83 }
84
85 fn threadCount(self: FrameSet) u64 {
86 return @intCast(self.threads.items.len);
87 }
88 };
89
90 pub const Summary = struct {
91 name: []const u8,
92 marks: u64,
93 frames: u64,
94 total_ns: u64,
95 mean_ns: u64,
96 min_ns: u64,
97 p50_ns: u64,
98 p90_ns: u64,
99 p99_ns: u64,
100 max_ns: u64,
101 first_ns: u64,
102 last_ns: u64,
103 threads: u64,
104 frame_time_discrepancy_ns: u64,
105 budget: ?BudgetSummary,
106 };
107
108 const SlowFrameView = struct {
109 set: []const u8,
110 index: u64,
111 start_ns: u64,
112 end_ns: u64,
113 duration_ns: u64,
114 thread: u64,
115 budget: ?FrameBudget,
116 };
117
118 pub const BudgetSummary = struct {
119 budget_ns: u64,
120 over_budget_frames: u64 = 0,
121 estimated_missed_intervals: u64 = 0,
122 total_overrun_ns: u64 = 0,
123 max_overrun_ns: u64 = 0,
124 longest_overrun_streak: u64 = 0,
125 longest_overrun_start_index: u64 = 0,
126 longest_overrun_end_index: u64 = 0,
127 longest_overrun_start_ns: u64 = 0,
128 longest_overrun_end_ns: u64 = 0,
129 longest_overrun_set: ?[]const u8 = null,
130 };
131
132 const FrameBudget = struct {
133 budget_ns: u64,
134 over_budget: bool,
135 overrun_ns: u64,
136 estimated_missed_intervals: u64,
137 };
138
139 pub const Analyzer = struct {
140 allocator: std.mem.Allocator,
141 capture: capture_mod.Tracker = .{},
142 sets: std.StringHashMapUnmanaged(FrameSet) = .{},
143 counters: Counters = .{},
144 start_ns: ?u64 = null,
145 end_ns: ?u64 = null,
146
147 pub fn init(allocator: std.mem.Allocator) Analyzer {
148 return .{ .allocator = allocator };
149 }
150
151 pub fn deinit(self: *Analyzer) void {
152 var iter = self.sets.iterator();
153 while (iter.next()) |entry| {
154 self.allocator.free(entry.key_ptr.*);
155 entry.value_ptr.deinit(self.allocator);
156 }
157 self.sets.deinit(self.allocator);
158 self.* = undefined;
159 }
160
161 pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
162 var lines = std.mem.splitScalar(u8, bytes, '\n');
163 while (lines.next()) |line| try self.ingestJsonLine(line);
164 }
165
166 pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
167 const text = std.mem.trim(u8, line, " \t\r\n");
168 if (text.len == 0) return;
169 var parsed = try record_mod.parseLine(self.allocator, text);
170 defer parsed.deinit();
171 switch (parsed) {
172 .event => |value| try self.ingest(value),
173 .flight => |report_value| self.capture.recordFlightReport(report_value),
174 }
175 }
176
177 pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
178 self.capture.record(parsed);
179 if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
180 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
181 switch (parsed.kind) {
182 .start => {
183 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
184 },
185 .stop => {
186 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
187 },
188 .frame => try self.recordFrame(parsed),
189 else => {},
190 }
191 }
192
193 pub fn durationNs(self: Analyzer) u64 {
194 const start_ns = self.start_ns orelse return 0;
195 const end_ns = self.end_ns orelse return 0;
196 if (end_ns <= start_ns) return 0;
197 return end_ns - start_ns;
198 }
199
200 pub fn captureIntegrity(self: *const Analyzer) CaptureIntegrity {
201 return self.capture.integrity(0);
202 }
203
204 fn recordFrame(self: *Analyzer, parsed: event.Parsed) !void {
205 const name = parsed.name orelse "default";
206 const set = try self.frameSet(name);
207 set.marks += 1;
208 std.debug.assert(set.marks > 0);
209 try appendThread(self.allocator, &set.threads, parsed.thread);
210 if (set.first_ns == null) set.first_ns = parsed.time_ns;
211 if (set.last_mark_ns) |last_mark_ns| {
212 if (parsed.time_ns < last_mark_ns) {
213 self.counters.out_of_order_marks += 1;
214 self.counters.marks += 1;
215 return;
216 }
217 const duration = parsed.time_ns - last_mark_ns;
218 const index: u64 = @intCast(set.frames.items.len + 1);
219 try set.frames.append(self.allocator, .{
220 .index = index,
221 .start_ns = last_mark_ns,
222 .end_ns = parsed.time_ns,
223 .duration_ns = duration,
224 .thread = set.last_thread,
225 });
226 std.debug.assert(set.frames.items[set.frames.items.len - 1].end_ns >= last_mark_ns);
227 set.total_ns +|= duration;
228 set.min_ns = @min(set.min_ns, duration);
229 set.max_ns = @max(set.max_ns, duration);
230 self.counters.frames += 1;
231 std.debug.assert(set.frames.items.len <= set.marks);
232 }
233 set.last_mark_ns = parsed.time_ns;
234 set.last_thread = parsed.thread;
235 set.last_ns = parsed.time_ns;
236 self.counters.marks += 1;
237 }
238
239 fn frameSet(self: *Analyzer, name: []const u8) !*FrameSet {
240 const entry = try self.sets.getOrPut(self.allocator, name);
241 if (!entry.found_existing) {
242 const owned_name = try self.allocator.dupe(u8, name);
243 entry.key_ptr.* = owned_name;
244 entry.value_ptr.* = .{ .name = owned_name };
245 }
246 return entry.value_ptr;
247 }
248 };
249
250 pub fn writeTextFromJsonlPath(
251 allocator: std.mem.Allocator,
252 path: []const u8,
253 writer: *std.Io.Writer,
254 options: Options,
255 ) !void {
256 return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
257 }
258
259 pub fn writeJsonlFromJsonlPath(
260 allocator: std.mem.Allocator,
261 path: []const u8,
262 writer: *std.Io.Writer,
263 options: Options,
264 ) !void {
265 return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
266 }
267
268 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
269 return report.ingestJsonlPath(analyzer, path);
270 }
271
272 fn writeText(
273 allocator: std.mem.Allocator,
274 analyzer: *Analyzer,
275 writer: *std.Io.Writer,
276 options: Options,
277 ) !void {
278 try validateOptions(options);
279 var sets = try collectSummaries(allocator, analyzer, options);
280 defer sets.deinit(allocator);
281 var slow_frames = try collectSlowFrames(allocator, analyzer, options);
282 defer slow_frames.deinit(allocator);
283 const budget = combinedBudget(sets.items, options.budget_ns);
284
285 try writer.print(
286 "tracy frames sets={d} marks={d} frames={d} out_of_order_marks={d}" ++
287 " duration_ns={d} sort={s}",
288 .{
289 sets.items.len,
290 analyzer.counters.marks,
291 analyzer.counters.frames,
292 analyzer.counters.out_of_order_marks,
293 analyzer.durationNs(),
294 options.sort.tag(),
295 },
296 );
297 try writeBudgetText(writer, budget);
298 try writer.writeByte('\n');
299 try capture_mod.writeText(writer, analyzer.captureIntegrity());
300 const set_limit = @min(options.top, sets.items.len);
301 for (sets.items[0..set_limit]) |set| {
302 try writer.writeAll("frame-set name=");
303 try pretty_json.writeString(writer, set.name);
304 try writer.print(
305 " marks={d} frames={d} total_ns={d} mean_ns={d} min_ns={d}" ++
306 " p50_ns={d} p90_ns={d} p99_ns={d} max_ns={d}" ++
307 " first_ns={d} last_ns={d} threads={d}" ++
308 " frame_time_discrepancy_ns={d}",
309 .{
310 set.marks,
311 set.frames,
312 set.total_ns,
313 set.mean_ns,
314 set.min_ns,
315 set.p50_ns,
316 set.p90_ns,
317 set.p99_ns,
318 set.max_ns,
319 set.first_ns,
320 set.last_ns,
321 set.threads,
322 set.frame_time_discrepancy_ns,
323 },
324 );
325 try writeBudgetText(writer, set.budget);
326 try writer.writeByte('\n');
327 }
328 const slow_limit = @min(options.top, slow_frames.items.len);
329 for (slow_frames.items[0..slow_limit]) |frame| {
330 try writer.writeAll("slow-frame set=");
331 try pretty_json.writeString(writer, frame.set);
332 try writer.print(
333 " index={d} duration_ns={d} start_ns={d} end_ns={d} thread={d}",
334 .{ frame.index, frame.duration_ns, frame.start_ns, frame.end_ns, frame.thread },
335 );
336 try writeFrameBudgetText(writer, frame.budget);
337 try writer.writeByte('\n');
338 }
339 }
340
341 fn writeJsonl(
342 allocator: std.mem.Allocator,
343 analyzer: *Analyzer,
344 writer: *std.Io.Writer,
345 options: Options,
346 ) !void {
347 try validateOptions(options);
348 var sets = try collectSummaries(allocator, analyzer, options);
349 defer sets.deinit(allocator);
350 var slow_frames = try collectSlowFrames(allocator, analyzer, options);
351 defer slow_frames.deinit(allocator);
352 const budget = combinedBudget(sets.items, options.budget_ns);
353
354 try writeJsonSummary(writer, analyzer, sets.items.len, options.sort, budget);
355
356 const set_limit = @min(options.top, sets.items.len);
357 for (sets.items[0..set_limit]) |set| {
358 var stream = pretty_json.Writer.init(writer, .minified);
359 const object = try stream.object();
360 try object.field("schema", schema);
361 try object.field("kind", "set");
362 try object.field("name", set.name);
363 try object.field("marks", set.marks);
364 try object.field("frames", set.frames);
365 try object.field("total_ns", set.total_ns);
366 try object.field("mean_ns", set.mean_ns);
367 try object.field("min_ns", set.min_ns);
368 try object.field("p50_ns", set.p50_ns);
369 try object.field("p90_ns", set.p90_ns);
370 try object.field("p99_ns", set.p99_ns);
371 try object.field("max_ns", set.max_ns);
372 try object.field("first_ns", set.first_ns);
373 try object.field("last_ns", set.last_ns);
374 try object.field("threads", set.threads);
375 try object.field("frame_time_discrepancy_ns", set.frame_time_discrepancy_ns);
376 try writeBudgetField(object, set.budget);
377 try object.endLine();
378 }
379
380 const slow_limit = @min(options.top, slow_frames.items.len);
381 for (slow_frames.items[0..slow_limit]) |frame| {
382 var stream = pretty_json.Writer.init(writer, .minified);
383 const object = try stream.object();
384 try object.field("schema", schema);
385 try object.field("kind", "slow_frame");
386 try object.field("set", frame.set);
387 try object.field("index", frame.index);
388 try object.field("duration_ns", frame.duration_ns);
389 try object.field("start_ns", frame.start_ns);
390 try object.field("end_ns", frame.end_ns);
391 try object.field("thread", frame.thread);
392 try writeFrameBudgetField(object, frame.budget);
393 try object.endLine();
394 }
395 }
396
397 fn writeJsonSummary(
398 writer: *std.Io.Writer,
399 analyzer: *Analyzer,
400 set_count: usize,
401 sort: Sort,
402 budget: ?BudgetSummary,
403 ) !void {
404 var stream = pretty_json.Writer.init(writer, .minified);
405 const object = try stream.object();
406 try object.field("schema", schema);
407 try object.field("kind", "summary");
408 try object.field("sets", set_count);
409 try object.field("marks", analyzer.counters.marks);
410 try object.field("frames", analyzer.counters.frames);
411 try object.field("out_of_order_marks", analyzer.counters.out_of_order_marks);
412 try object.field("duration_ns", analyzer.durationNs());
413 try object.field("sort", sort.tag());
414 try writeBudgetField(object, budget);
415 try capture_mod.writeFields(object, analyzer.captureIntegrity());
416 try object.endLine();
417 }
418
419 pub fn collectSummaries(
420 allocator: std.mem.Allocator,
421 analyzer: *Analyzer,
422 options: Options,
423 ) !std.ArrayListUnmanaged(Summary) {
424 if (options.budget_ns) |budget| std.debug.assert(budget > 0);
425 var views: std.ArrayListUnmanaged(Summary) = .empty;
426 errdefer views.deinit(allocator);
427 var iter = analyzer.sets.valueIterator();
428 while (iter.next()) |set| {
429 if (options.set) |wanted| {
430 if (!std.mem.eql(u8, wanted, set.name)) continue;
431 }
432 const view = try setView(allocator, set.*, options.budget_ns);
433 if (view.frames == 0 and options.min_frame_ns != 0) continue;
434 if (view.max_ns < options.min_frame_ns) continue;
435 try views.append(allocator, view);
436 }
437 sortSetViews(views.items, options.sort);
438 return views;
439 }
440
441 fn collectSlowFrames(
442 allocator: std.mem.Allocator,
443 analyzer: *Analyzer,
444 options: Options,
445 ) !std.ArrayListUnmanaged(SlowFrameView) {
446 if (options.budget_ns) |budget| std.debug.assert(budget > 0);
447 var rows: std.ArrayListUnmanaged(SlowFrameView) = .empty;
448 errdefer rows.deinit(allocator);
449 var iter = analyzer.sets.valueIterator();
450 while (iter.next()) |set| {
451 if (options.set) |wanted| {
452 if (!std.mem.eql(u8, wanted, set.name)) continue;
453 }
454 for (set.frames.items) |frame| {
455 if (frame.duration_ns < options.min_frame_ns) continue;
456 try rows.append(allocator, .{
457 .set = set.name,
458 .index = frame.index,
459 .start_ns = frame.start_ns,
460 .end_ns = frame.end_ns,
461 .duration_ns = frame.duration_ns,
462 .thread = frame.thread,
463 .budget = frameBudget(frame.duration_ns, options.budget_ns),
464 });
465 }
466 }
467 std.mem.sort(SlowFrameView, rows.items, {}, slowFrameGreaterThan);
468 return rows;
469 }
470
471 fn setView(
472 allocator: std.mem.Allocator,
473 set: FrameSet,
474 budget_ns: ?u64,
475 ) !Summary {
476 std.debug.assert(set.frames.items.len <= set.marks);
477 var durations = try allocator.alloc(u64, set.frames.items.len);
478 defer allocator.free(durations);
479 for (set.frames.items, 0..) |frame, index| durations[index] = frame.duration_ns;
480 std.mem.sort(u64, durations, {}, std.sort.asc(u64));
481 return .{
482 .name = set.name,
483 .marks = set.marks,
484 .frames = @intCast(set.frames.items.len),
485 .total_ns = set.total_ns,
486 .mean_ns = set.meanNs(),
487 .min_ns = if (set.min_ns == std.math.maxInt(u64)) 0 else set.min_ns,
488 .p50_ns = percentile(durations, 50),
489 .p90_ns = percentile(durations, 90),
490 .p99_ns = percentile(durations, 99),
491 .max_ns = set.max_ns,
492 .first_ns = set.first_ns orelse 0,
493 .last_ns = set.last_ns orelse 0,
494 .threads = set.threadCount(),
495 .frame_time_discrepancy_ns = frameTimeDiscrepancyNs(set.frames.items),
496 .budget = budgetStats(set.frames.items, budget_ns),
497 };
498 }
499
500 fn validateOptions(options: Options) !void {
501 if (options.budget_ns == 0) return error.InvalidBudget;
502 }
503
504 fn frameBudget(duration_ns: u64, budget_ns: ?u64) ?FrameBudget {
505 const budget = budget_ns orelse return null;
506 std.debug.assert(budget > 0);
507 const overrun = duration_ns -| budget;
508 return .{
509 .budget_ns = budget,
510 .over_budget = duration_ns > budget,
511 .overrun_ns = overrun,
512 .estimated_missed_intervals = estimatedMissedIntervals(duration_ns, budget),
513 };
514 }
515
516 fn budgetStats(frames: []const FrameRecord, budget_ns: ?u64) ?BudgetSummary {
517 const budget = budget_ns orelse return null;
518 std.debug.assert(budget > 0);
519 var result = BudgetSummary{ .budget_ns = budget };
520 var streak: u64 = 0;
521 var streak_start: u64 = 0;
522 var streak_start_ns: u64 = 0;
523 for (frames) |frame| {
524 std.debug.assert(frame.end_ns >= frame.start_ns);
525 std.debug.assert(frame.duration_ns == frame.end_ns - frame.start_ns);
526 if (frame.duration_ns <= budget) {
527 streak = 0;
528 streak_start = 0;
529 streak_start_ns = 0;
530 continue;
531 }
532 const overrun = frame.duration_ns - budget;
533 result.over_budget_frames +|= 1;
534 result.estimated_missed_intervals +|= estimatedMissedIntervals(
535 frame.duration_ns,
536 budget,
537 );
538 result.total_overrun_ns +|= overrun;
539 result.max_overrun_ns = @max(result.max_overrun_ns, overrun);
540 if (streak == 0) {
541 streak_start = frame.index;
542 streak_start_ns = frame.start_ns;
543 }
544 streak +|= 1;
545 if (streak > result.longest_overrun_streak) {
546 result.longest_overrun_streak = streak;
547 result.longest_overrun_start_index = streak_start;
548 result.longest_overrun_end_index = frame.index;
549 result.longest_overrun_start_ns = streak_start_ns;
550 result.longest_overrun_end_ns = frame.end_ns;
551 }
552 }
553 if (std.math.cast(u64, frames.len)) |frame_count| {
554 std.debug.assert(result.over_budget_frames <= frame_count);
555 }
556 return result;
557 }
558
559 fn combinedBudget(sets: []const Summary, budget_ns: ?u64) ?BudgetSummary {
560 const budget = budget_ns orelse return null;
561 var result = BudgetSummary{ .budget_ns = budget };
562 for (sets) |set| {
563 const current = set.budget orelse continue;
564 std.debug.assert(current.budget_ns == budget);
565 result.over_budget_frames +|= current.over_budget_frames;
566 result.estimated_missed_intervals +|= current.estimated_missed_intervals;
567 result.total_overrun_ns +|= current.total_overrun_ns;
568 result.max_overrun_ns = @max(result.max_overrun_ns, current.max_overrun_ns);
569 if (preferLongestOverrun(current, set.name, result)) {
570 result.longest_overrun_streak = current.longest_overrun_streak;
571 result.longest_overrun_start_index = current.longest_overrun_start_index;
572 result.longest_overrun_end_index = current.longest_overrun_end_index;
573 result.longest_overrun_start_ns = current.longest_overrun_start_ns;
574 result.longest_overrun_end_ns = current.longest_overrun_end_ns;
575 result.longest_overrun_set = set.name;
576 }
577 }
578 return result;
579 }
580
581 fn preferLongestOverrun(
582 current: BudgetSummary,
583 set_name: []const u8,
584 selected: BudgetSummary,
585 ) bool {
586 if (current.longest_overrun_streak > selected.longest_overrun_streak) return true;
587 if (current.longest_overrun_streak < selected.longest_overrun_streak) return false;
588 if (current.longest_overrun_streak == 0) return false;
589 const selected_name = selected.longest_overrun_set orelse return true;
590 return std.mem.lessThan(u8, set_name, selected_name);
591 }
592
593 fn estimatedMissedIntervals(duration_ns: u64, budget_ns: u64) u64 {
594 std.debug.assert(budget_ns > 0);
595 if (duration_ns <= budget_ns) return 0;
596 return (duration_ns - 1) / budget_ns;
597 }
598
599 fn frameTimeDiscrepancyNs(frames: []const FrameRecord) u64 {
600 if (frames.len == 0) return 0;
601 const first_ns = frames[0].start_ns;
602 const last_ns = frames[frames.len - 1].end_ns;
603 if (last_ns <= first_ns) return 0;
604 const range: i256 = @intCast(last_ns - first_ns);
605 const intervals: i256 = @intCast(frames.len);
606 const denominator = 2 * intervals;
607 var minimum = -range;
608 var maximum = range;
609 var previous_ns = first_ns;
610 for (frames, 0..) |frame, frame_index| {
611 std.debug.assert(frame.start_ns == previous_ns);
612 std.debug.assert(frame.end_ns >= frame.start_ns);
613 std.debug.assert(frame.duration_ns == frame.end_ns - frame.start_ns);
614 previous_ns = frame.end_ns;
615 const point: i256 = @as(i256, @intCast(frame_index)) + 2;
616 const time_ns = frame.end_ns;
617 const offset: i256 = @intCast(time_ns - first_ns);
618 const time_term = denominator * offset;
619 const before = (2 * point - 3) * range - time_term;
620 const after = (2 * point - 1) * range - time_term;
621 minimum = @min(minimum, before);
622 maximum = @max(maximum, after);
623 }
624 const spread = maximum - minimum;
625 const rounded = @divTrunc(spread + denominator - 1, denominator);
626 return @intCast(@min(rounded, std.math.maxInt(u64)));
627 }
628
629 fn percentile(sorted: []const u64, percent: u64) u64 {
630 if (sorted.len == 0) return 0;
631 const rank: usize = @intCast((@as(u128, percent) * sorted.len + 99) / 100);
632 const index = @min(@max(rank, 1) - 1, sorted.len - 1);
633 return sorted[index];
634 }
635
636 fn appendThread(
637 allocator: std.mem.Allocator,
638 threads: *std.ArrayListUnmanaged(u64),
639 thread: u64,
640 ) !void {
641 for (threads.items) |existing| {
642 if (existing == thread) return;
643 }
644 try threads.append(allocator, thread);
645 }
646
647 fn sortSetViews(items: []Summary, sort: Sort) void {
648 switch (sort) {
649 .max => std.mem.sort(Summary, items, {}, setMaxGreaterThan),
650 .total => std.mem.sort(Summary, items, {}, setTotalGreaterThan),
651 .mean => std.mem.sort(Summary, items, {}, setMeanGreaterThan),
652 .count => std.mem.sort(Summary, items, {}, setCountGreaterThan),
653 .name => std.mem.sort(Summary, items, {}, setNameLessThan),
654 }
655 }
656
657 fn setMaxGreaterThan(_: void, left: Summary, right: Summary) bool {
658 if (left.max_ns != right.max_ns) return left.max_ns > right.max_ns;
659 return setNameLessThan({}, left, right);
660 }
661
662 fn setTotalGreaterThan(_: void, left: Summary, right: Summary) bool {
663 if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
664 return setNameLessThan({}, left, right);
665 }
666
667 fn setMeanGreaterThan(_: void, left: Summary, right: Summary) bool {
668 if (left.mean_ns != right.mean_ns) return left.mean_ns > right.mean_ns;
669 return setTotalGreaterThan({}, left, right);
670 }
671
672 fn setCountGreaterThan(_: void, left: Summary, right: Summary) bool {
673 if (left.frames != right.frames) return left.frames > right.frames;
674 return setTotalGreaterThan({}, left, right);
675 }
676
677 fn setNameLessThan(_: void, left: Summary, right: Summary) bool {
678 return std.mem.lessThan(u8, left.name, right.name);
679 }
680
681 fn slowFrameGreaterThan(_: void, left: SlowFrameView, right: SlowFrameView) bool {
682 if (left.duration_ns != right.duration_ns) return left.duration_ns > right.duration_ns;
683 const set_cmp = std.mem.order(u8, left.set, right.set);
684 if (set_cmp != .eq) return set_cmp == .lt;
685 return left.index < right.index;
686 }
687
688 fn writeBudgetText(writer: *std.Io.Writer, budget: ?BudgetSummary) !void {
689 const value = budget orelse {
690 try writer.writeAll(" budget=none");
691 return;
692 };
693 try writer.print(
694 " budget_ns={d} over_budget_frames={d} estimated_missed_intervals={d}" ++
695 " total_overrun_ns={d} max_overrun_ns={d} longest_overrun_streak={d}" ++
696 " longest_overrun_start_index={d} longest_overrun_end_index={d}" ++
697 " longest_overrun_start_ns={d} longest_overrun_end_ns={d}",
698 .{
699 value.budget_ns,
700 value.over_budget_frames,
701 value.estimated_missed_intervals,
702 value.total_overrun_ns,
703 value.max_overrun_ns,
704 value.longest_overrun_streak,
705 value.longest_overrun_start_index,
706 value.longest_overrun_end_index,
707 value.longest_overrun_start_ns,
708 value.longest_overrun_end_ns,
709 },
710 );
711 if (value.longest_overrun_set) |set| {
712 try writer.writeAll(" longest_overrun_set=");
713 try pretty_json.writeString(writer, set);
714 }
715 }
716
717 fn writeFrameBudgetText(writer: *std.Io.Writer, budget: ?FrameBudget) !void {
718 const value = budget orelse {
719 try writer.writeAll(" budget=none");
720 return;
721 };
722 try writer.print(
723 " budget_ns={d} over_budget={} overrun_ns={d} estimated_missed_intervals={d}",
724 .{
725 value.budget_ns,
726 value.over_budget,
727 value.overrun_ns,
728 value.estimated_missed_intervals,
729 },
730 );
731 }
732
733 fn writeBudgetField(object: pretty_json.Object, budget: ?BudgetSummary) !void {
734 const value = budget orelse {
735 try object.field("budget", null);
736 return;
737 };
738 const budget_object = try object.object("budget");
739 try budget_object.field("budget_ns", value.budget_ns);
740 try budget_object.field("over_budget_frames", value.over_budget_frames);
741 try budget_object.field("estimated_missed_intervals", value.estimated_missed_intervals);
742 try budget_object.field("total_overrun_ns", value.total_overrun_ns);
743 try budget_object.field("max_overrun_ns", value.max_overrun_ns);
744 try budget_object.field("longest_overrun_streak", value.longest_overrun_streak);
745 try budget_object.field("longest_overrun_start_index", value.longest_overrun_start_index);
746 try budget_object.field("longest_overrun_end_index", value.longest_overrun_end_index);
747 try budget_object.field("longest_overrun_start_ns", value.longest_overrun_start_ns);
748 try budget_object.field("longest_overrun_end_ns", value.longest_overrun_end_ns);
749 if (value.longest_overrun_set) |set| try budget_object.field("longest_overrun_set", set);
750 try budget_object.end();
751 }
752
753 fn writeFrameBudgetField(object: pretty_json.Object, budget: ?FrameBudget) !void {
754 const value = budget orelse {
755 try object.field("budget", null);
756 return;
757 };
758 const budget_object = try object.object("budget");
759 try budget_object.field("budget_ns", value.budget_ns);
760 try budget_object.field("over_budget", value.over_budget);
761 try budget_object.field("overrun_ns", value.overrun_ns);
762 try budget_object.field("estimated_missed_intervals", value.estimated_missed_intervals);
763 try budget_object.end();
764 }
765
766 test "frames aggregate named and default continuous marks" {
767 var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
768 defer trace.deinit();
769 try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
770 try (event.TraceEvent{ .seq = 2, .kind = .frame, .time_ns = 100, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
771 try (event.TraceEvent{ .seq = 3, .kind = .frame, .time_ns = 125, .thread = 2 }).writeJsonLine(&trace.writer);
772 try (event.TraceEvent{ .seq = 4, .kind = .frame, .time_ns = 130, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
773 try (event.TraceEvent{ .seq = 5, .kind = .frame, .time_ns = 170, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
774 try (event.TraceEvent{ .seq = 6, .kind = .frame, .time_ns = 225, .thread = 2 }).writeJsonLine(&trace.writer);
775 try (event.TraceEvent{ .seq = 7, .kind = .frame, .time_ns = 250, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
776 try (event.TraceEvent{ .seq = 8, .kind = .stop, .time_ns = 260, .thread = 1 }).writeJsonLine(&trace.writer);
777
778 var analyzer = Analyzer.init(std.testing.allocator);
779 defer analyzer.deinit();
780 try analyzer.ingestJsonlBytes(trace.written());
781 try std.testing.expectEqual(@as(u64, 6), analyzer.counters.marks);
782 try std.testing.expectEqual(@as(u64, 4), analyzer.counters.frames);
783
784 var out = std.Io.Writer.Allocating.init(std.testing.allocator);
785 defer out.deinit();
786 try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .sort = .name });
787 const text = out.written();
788 try std.testing.expect(std.mem.indexOf(u8, text, "tracy frames sets=2 marks=6 frames=4") != null);
789 try std.testing.expect(std.mem.indexOf(u8, text, "capture_integrity=complete") != null);
790 try std.testing.expect(std.mem.indexOf(u8, text, "budget=none") != null);
791 try std.testing.expect(std.mem.indexOf(u8, text, "frame-set name=\"main\" marks=4 frames=3 total_ns=150 mean_ns=50 min_ns=30 p50_ns=40 p90_ns=80 p99_ns=80 max_ns=80 first_ns=100 last_ns=250 threads=1") != null);
792 try std.testing.expect(std.mem.indexOf(u8, text, "frame-set name=\"default\" marks=2 frames=1 total_ns=100 mean_ns=100") != null);
793 try std.testing.expect(std.mem.indexOf(u8, text, "slow-frame set=\"default\" index=1 duration_ns=100 start_ns=125 end_ns=225 thread=2") != null);
794
795 var json = std.Io.Writer.Allocating.init(std.testing.allocator);
796 defer json.deinit();
797 try writeJsonl(std.testing.allocator, &analyzer, &json.writer, .{ .top = 0 });
798 try std.testing.expect(std.mem.indexOf(u8, json.written(), "\"budget\":null") != null);
799 try std.testing.expect(std.mem.indexOf(
800 u8,
801 json.written(),
802 "\"capture_integrity\":{\"method\":\"tracy_event_sequence_and_lifecycle_v1\"",
803 ) != null);
804 }
805
806 test "frames keep partial cadence evidence beside its capture caveat" {
807 var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
808 defer trace.deinit();
809 try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&trace.writer);
810 try (event.TraceEvent{ .seq = 3, .kind = .frame, .time_ns = 100 })
811 .writeJsonLine(&trace.writer);
812 try (event.TraceEvent{ .seq = 4, .kind = .frame, .time_ns = 125 })
813 .writeJsonLine(&trace.writer);
814 try (event.TraceEvent{ .seq = 5, .kind = .stop }).writeJsonLine(&trace.writer);
815 var analyzer = Analyzer.init(std.testing.allocator);
816 defer analyzer.deinit();
817 try analyzer.ingestJsonlBytes(trace.written());
818
819 var text = std.Io.Writer.Allocating.init(std.testing.allocator);
820 defer text.deinit();
821 try writeText(std.testing.allocator, &analyzer, &text.writer, .{});
822 try std.testing.expect(std.mem.indexOf(u8, text.written(), "frames=1") != null);
823 try std.testing.expect(std.mem.indexOf(
824 u8,
825 text.written(),
826 "capture_integrity=sequence_gaps",
827 ) != null);
828 try std.testing.expect(std.mem.indexOf(
829 u8,
830 text.written(),
831 "action=inspect_loss_counters_or_increase_capacity",
832 ) != null);
833
834 var json = std.Io.Writer.Allocating.init(std.testing.allocator);
835 defer json.deinit();
836 try writeJsonl(std.testing.allocator, &analyzer, &json.writer, .{ .top = 0 });
837 try std.testing.expect(std.mem.indexOf(u8, json.written(), "\"frames\":1") != null);
838 try std.testing.expect(std.mem.indexOf(
839 u8,
840 json.written(),
841 "\"status\":\"sequence_gaps\"",
842 ) != null);
843 }
844
845 test "frames jsonl emits filtered set and slow frame rows" {
846 var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
847 defer trace.deinit();
848 try (event.TraceEvent{ .seq = 1, .kind = .frame, .time_ns = 100, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
849 try (event.TraceEvent{ .seq = 2, .kind = .frame, .time_ns = 110, .thread = 1, .name = "other" }).writeJsonLine(&trace.writer);
850 try (event.TraceEvent{ .seq = 3, .kind = .frame, .time_ns = 190, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
851 try (event.TraceEvent{ .seq = 4, .kind = .frame, .time_ns = 210, .thread = 1, .name = "other" }).writeJsonLine(&trace.writer);
852
853 var analyzer = Analyzer.init(std.testing.allocator);
854 defer analyzer.deinit();
855 try analyzer.ingestJsonlBytes(trace.written());
856
857 var out = std.Io.Writer.Allocating.init(std.testing.allocator);
858 defer out.deinit();
859 try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{
860 .top = 4,
861 .set = "main",
862 .min_frame_ns = 80,
863 .budget_ns = 80,
864 });
865 const text = out.written();
866 try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.frames/v0\"") != null);
867 try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"set\"") != null);
868 try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"main\"") != null);
869 try std.testing.expect(std.mem.indexOf(u8, text, "\"duration_ns\":90") != null);
870 try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"slow_frame\"") != null);
871 try std.testing.expect(std.mem.indexOf(u8, text, "\"over_budget_frames\":1") != null);
872 try std.testing.expect(std.mem.indexOf(u8, text, "\"longest_overrun_set\":\"main\"") != null);
873 try std.testing.expect(std.mem.indexOf(u8, text, "\"overrun_ns\":10") != null);
874 try std.testing.expect(std.mem.indexOf(u8, text, "\"estimated_missed_intervals\":1") != null);
875 try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"other\"") == null);
876
877 var lines = std.mem.tokenizeScalar(u8, text, '\n');
878 var line_count: usize = 0;
879 while (lines.next()) |line| {
880 var parsed = try std.json.parseFromSlice(
881 std.json.Value,
882 std.testing.allocator,
883 line,
884 .{},
885 );
886 parsed.deinit();
887 line_count += 1;
888 }
889 try std.testing.expectEqual(@as(usize, 3), line_count);
890 }
891
892 test "frames budget counts severity and consecutive overruns" {
893 const frames = [_]FrameRecord{
894 .{ .index = 1, .start_ns = 0, .end_ns = 10, .duration_ns = 10, .thread = 1 },
895 .{ .index = 2, .start_ns = 10, .end_ns = 31, .duration_ns = 21, .thread = 1 },
896 .{ .index = 3, .start_ns = 31, .end_ns = 76, .duration_ns = 45, .thread = 1 },
897 .{ .index = 4, .start_ns = 76, .end_ns = 86, .duration_ns = 10, .thread = 1 },
898 .{ .index = 5, .start_ns = 86, .end_ns = 117, .duration_ns = 31, .thread = 1 },
899 .{ .index = 6, .start_ns = 117, .end_ns = 149, .duration_ns = 32, .thread = 1 },
900 .{ .index = 7, .start_ns = 149, .end_ns = 182, .duration_ns = 33, .thread = 1 },
901 .{ .index = 8, .start_ns = 182, .end_ns = 192, .duration_ns = 10, .thread = 1 },
902 };
903 const budget = budgetStats(&frames, 20).?;
904 try std.testing.expectEqual(@as(u64, 5), budget.over_budget_frames);
905 try std.testing.expectEqual(@as(u64, 6), budget.estimated_missed_intervals);
906 try std.testing.expectEqual(@as(u64, 62), budget.total_overrun_ns);
907 try std.testing.expectEqual(@as(u64, 25), budget.max_overrun_ns);
908 try std.testing.expectEqual(@as(u64, 3), budget.longest_overrun_streak);
909 try std.testing.expectEqual(@as(u64, 5), budget.longest_overrun_start_index);
910 try std.testing.expectEqual(@as(u64, 7), budget.longest_overrun_end_index);
911 try std.testing.expectEqual(@as(u64, 86), budget.longest_overrun_start_ns);
912 try std.testing.expectEqual(@as(u64, 182), budget.longest_overrun_end_ns);
913 }
914
915 test "frames do not classify exact budget equality as an overrun" {
916 const exact = frameBudget(20, 20).?;
917 try std.testing.expect(!exact.over_budget);
918 try std.testing.expectEqual(@as(u64, 0), exact.overrun_ns);
919 try std.testing.expectEqual(@as(u64, 0), exact.estimated_missed_intervals);
920 try std.testing.expectEqual(@as(u64, 1), estimatedMissedIntervals(40, 20));
921 try std.testing.expectEqual(@as(u64, 2), estimatedMissedIntervals(41, 20));
922 }
923
924 test "frames break longest overrun ties by set name" {
925 var selected = BudgetSummary{
926 .budget_ns = 20,
927 .longest_overrun_streak = 2,
928 .longest_overrun_set = "render",
929 };
930 const current = BudgetSummary{ .budget_ns = 20, .longest_overrun_streak = 2 };
931 try std.testing.expect(preferLongestOverrun(current, "physics", selected));
932 selected.longest_overrun_set = "physics";
933 try std.testing.expect(!preferLongestOverrun(current, "render", selected));
934 }
935
936 test "frames discrepancy distinguishes clustered intervals" {
937 const uniform = [_]FrameRecord{
938 .{ .index = 1, .start_ns = 0, .end_ns = 10, .duration_ns = 10, .thread = 1 },
939 .{ .index = 2, .start_ns = 10, .end_ns = 20, .duration_ns = 10, .thread = 1 },
940 .{ .index = 3, .start_ns = 20, .end_ns = 30, .duration_ns = 10, .thread = 1 },
941 .{ .index = 4, .start_ns = 30, .end_ns = 40, .duration_ns = 10, .thread = 1 },
942 };
943 const separated = [_]FrameRecord{
944 .{ .index = 1, .start_ns = 0, .end_ns = 20, .duration_ns = 20, .thread = 1 },
945 .{ .index = 2, .start_ns = 20, .end_ns = 30, .duration_ns = 10, .thread = 1 },
946 .{ .index = 3, .start_ns = 30, .end_ns = 50, .duration_ns = 20, .thread = 1 },
947 .{ .index = 4, .start_ns = 50, .end_ns = 60, .duration_ns = 10, .thread = 1 },
948 };
949 const clustered = [_]FrameRecord{
950 .{ .index = 1, .start_ns = 0, .end_ns = 20, .duration_ns = 20, .thread = 1 },
951 .{ .index = 2, .start_ns = 20, .end_ns = 40, .duration_ns = 20, .thread = 1 },
952 .{ .index = 3, .start_ns = 40, .end_ns = 50, .duration_ns = 10, .thread = 1 },
953 .{ .index = 4, .start_ns = 50, .end_ns = 60, .duration_ns = 10, .thread = 1 },
954 };
955 try std.testing.expectEqual(@as(u64, 10), frameTimeDiscrepancyNs(&uniform));
956 try std.testing.expectEqual(@as(u64, 20), frameTimeDiscrepancyNs(&separated));
957 try std.testing.expectEqual(@as(u64, 25), frameTimeDiscrepancyNs(&clustered));
958 }
959
960 test "frames ignore out of order marks without shifting the cadence" {
961 var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
962 defer trace.deinit();
963 try (event.TraceEvent{ .seq = 1, .kind = .frame, .time_ns = 100, .thread = 1 })
964 .writeJsonLine(&trace.writer);
965 try (event.TraceEvent{ .seq = 2, .kind = .frame, .time_ns = 90, .thread = 1 })
966 .writeJsonLine(&trace.writer);
967 try (event.TraceEvent{ .seq = 3, .kind = .frame, .time_ns = 120, .thread = 1 })
968 .writeJsonLine(&trace.writer);
969 var analyzer = Analyzer.init(std.testing.allocator);
970 defer analyzer.deinit();
971 try analyzer.ingestJsonlBytes(trace.written());
972 try std.testing.expectEqual(@as(u64, 1), analyzer.counters.frames);
973 try std.testing.expectEqual(@as(u64, 1), analyzer.counters.out_of_order_marks);
974 const set = analyzer.sets.get("default").?;
975 try std.testing.expectEqual(@as(u64, 20), set.frames.items[0].duration_ns);
976 }
977
978 test "frames reject a zero direct budget" {
979 var analyzer = Analyzer.init(std.testing.allocator);
980 defer analyzer.deinit();
981 var scratch: [1]u8 = undefined;
982 var out = std.Io.Writer.Discarding.init(&scratch);
983 try std.testing.expectError(
984 error.InvalidBudget,
985 writeText(std.testing.allocator, &analyzer, &out.writer, .{ .budget_ns = 0 }),
986 );
987 }