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 }