lib/accy/src/profiling/scan/gate.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! Code that turns one benchmark variant's timed samples and its cycle counts
2 //! into one printed verdict against a lower bound, followed by lines naming the
3 //! work the bound leaves out. A reader of a benchmark log wants each timing
4 //! judged against the least time the host allows, and, when the difference is
5 //! large, told its cause and who owns it.
6 //!
7 //! A bound priced in cycles becomes nanoseconds only at the core frequency the
8 //! run itself observed, and the host may refuse to count cycles at all. Samples
9 //! spread upward from interruptions, so a middle sample can sit above the
10 //! typical launch. An error between opening a counter and reading it leaks the
11 //! counter's file descriptors unless something closes it.
12 //!
13 //! The bound comes from the scan floor file: one lower bound on hardware time
14 //! (*floor*) per launch shape, priced at the clock rate the timed launches
15 //! observed. The output is one line per variant (*gate*),
16 //! `profile gate <scope>: floor=<n>ns measured=<n>ns ratio=<n>`. When measured
17 //! time divided by the floor, rounded up (*ratio*), exceeds 10, the line adds
18 //! the gap's cause and its owner issue, the same threshold the publication
19 //! benchmark uses. The measured time is the smaller of the two middle samples
20 //! of a sorted run, or the middle one when the count is odd (*lower median*),
21 //! so the comparison with the floor uses the faster middle sample and never a
22 //! slower one. Cycles and wall time add up across the timed launches to give
23 //! the core clock rate in kilohertz (*clock*), and one launch whose counter
24 //! missed part of its region withdraws that clock, so the gate prints as
25 //! unavailable. Each gate line is followed by lines that say whether the floor
26 //! prices everything (*coverage*) and, when it does not, name each unpriced
27 //! piece. A floor above the measurement means the floor is wrong, and `write`
28 //! reports that as a defect. Each counter is closed once: `record` closes it
29 //! and empties it, so a `close` on an error path afterward does nothing.
30 //!
31 //! - *gap*: a named cause and the issue that owns it, attached to a gate whose
32 //! ratio exceeds a threshold.
33 //! - *scope*: the dotted name a gate reports under, such as
34 //! "accy.scan.iterate.scan".
35
36 const std = @import("std");
37 const bench = @import("bench");
38 const sys = @import("sys");
39 const floor_mod = @import("floor.zig");
40
41 const Allocator = std.mem.Allocator;
42 const floor = bench.floor;
43 const perf = sys.perf;
44
45 /// The ratio threshold is 10, above which a gate names its gap's cause and
46 /// owner. The value matches the publication benchmark's threshold, which that
47 /// file keeps as its own copy.
48 pub const gap_ratio: u64 = 10;
49
50 /// The cause of the gap as the measurement found it: the generated kernel
51 /// retires fifty instructions per lane-step to compute four.
52 pub const gap_cause =
53 "the generated kernel retires fifty instructions per lane-step to compute four";
54
55 /// The tracker id of the issue that owns closing the gap.
56 pub const gap_issue = "tiny-" ++ "1wgqc17y";
57
58 const hardware_selectors = [_]perf.CounterSelector{.{ .hardware = .cycles }};
59 const counter_options = perf.CounterOptions{ .exclude_kernel = true, .exclude_hv = true };
60
61 /// One open cycle counter, or null when the host refused to open one.
62 pub const Region = ?perf.CounterSetRegion(hardware_selectors.len);
63
64 /// Opens a counter of user-mode cycles for one timed launch, and returns null
65 /// when the host refuses.
66 pub fn open() Region {
67 std.debug.assert(hardware_selectors.len == 1);
68 return perf.CounterSetRegion(hardware_selectors.len).start(
69 &hardware_selectors,
70 counter_options,
71 ) catch null;
72 }
73
74 /// Closes a counter that no launch recorded and sets `region` to null, so an
75 /// error between opening a counter and recording it leaks no descriptors. A
76 /// counter already recorded is null, so this call then does nothing.
77 pub fn close(region: *Region) void {
78 if (region.*) |*region_open| region_open.deinit();
79 region.* = null;
80 }
81
82 /// Cycles and wall time summed over the timed launches to give their clock, the
83 /// core clock rate. One launch whose count covers only part of its timed region
84 /// cancels the clock to keep a partial count out of the figure.
85 pub const Cycles = struct {
86 total: u64 = 0,
87 elapsed_ns: u64 = 0,
88 counted: bool = true,
89
90 /// Adds one launch's wall time and cycles, then stops and closes its
91 /// counter and sets `region` to null, so a later `close` on an error path
92 /// never closes it twice. A launch with no counter, or with a count that
93 /// missed part of the region, marks the whole set as uncounted. The
94 /// parameter `ns` must be greater than zero.
95 pub fn record(self: *Cycles, region: *Region, ns: u64) void {
96 std.debug.assert(ns > 0);
97 self.elapsed_ns += ns;
98 std.debug.assert(self.elapsed_ns >= ns);
99 var complete = false;
100 if (region.*) |*region_open| {
101 if (region_open.stop() catch null) |counts| {
102 if (counts.find(hardware_selectors[0])) |found| {
103 if (found.runningCoverage() == .complete) {
104 self.total += found.value;
105 complete = true;
106 }
107 }
108 }
109 region_open.deinit();
110 }
111 region.* = null;
112 if (!complete) self.counted = false;
113 }
114
115 /// Returns the clock, the core clock rate, or null when any sample lost its
116 /// count or the clock cannot be computed.
117 pub fn clock(self: *const Cycles) ?floor.Clock {
118 if (!self.counted) return null;
119 std.debug.assert(self.elapsed_ns > 0);
120 return floor.Clock.observed(self.total, self.elapsed_ns) catch null;
121 }
122 };
123
124 /// Returns the lower median of `values`, the smaller of the two middle samples
125 /// of a sorted run, so the comparison with the floor uses the faster middle
126 /// sample and never a slower one. The call sorts a copy in `arena` and leaves
127 /// `values` in its original order, and `values` must not be empty.
128 pub fn lowerMedian(arena: Allocator, values: []const u64) !u64 {
129 std.debug.assert(values.len > 0);
130 const sorted = try arena.dupe(u64, values);
131 std.debug.assert(sorted.len == values.len);
132 std.mem.sort(u64, sorted, {}, std.sort.asc(u64));
133 const middle = sorted[(sorted.len - 1) / 2];
134 std.debug.assert(middle >= sorted[0]);
135 std.debug.assert(middle <= sorted[sorted.len - 1]);
136 return middle;
137 }
138
139 /// Prints one gate for `scope` and its coverage lines, and returns true when
140 /// the floor exceeds the measurement, after printing a `profile defect` line. A
141 /// floor above the measurement means the floor itself is wrong, and it never
142 /// means the launch ran fast. With no clock, the call prints the gate as
143 /// unavailable because cycles went uncounted, and returns false.
144 pub fn write(
145 arena: Allocator,
146 out: *std.Io.Writer,
147 scope: []const u8,
148 shape: floor_mod.Shape,
149 samples: []const u64,
150 clock: ?floor.Clock,
151 ) !bool {
152 std.debug.assert(scope.len > 0);
153 std.debug.assert(samples.len > 0);
154 const observed = clock orelse {
155 try out.print("profile gate {s}: unavailable, cycles uncounted\n", .{scope});
156 return false;
157 };
158 var entries = floor_mod.Entries{};
159 const derivation = floor_mod.launch(shape, &entries);
160 const estimate = try derivation.estimate(observed);
161 const floor_ns = try estimate.totalNs();
162 const measured_ns = try lowerMedian(arena, samples);
163 std.debug.assert(floor_ns > 0);
164 std.debug.assert(measured_ns > 0);
165 const ratio = floor.ceilingRatio(measured_ns, floor_ns);
166 std.debug.assert(ratio > 0);
167 const wide = ratio > gap_ratio;
168 const gate = floor.Gate{
169 .scope = scope,
170 .floor_ns = floor_ns,
171 .measured_ns = measured_ns,
172 .gap = if (wide) .{ .cause = gap_cause, .issue = gap_issue } else null,
173 };
174 try out.print("{f}", .{gate});
175 try writeCoverage(out, scope, derivation.coverage);
176 if (floor_ns <= measured_ns) return false;
177 try out.print("profile defect {s}: floor={d}ns measured={d}ns\n", .{
178 scope, floor_ns, measured_ns,
179 });
180 return true;
181 }
182
183 /// States after each gate line whether the floor covers all of the row's work,
184 /// and lists each unpriced piece on its own line when it does not. A wide ratio
185 /// then reads as work that nobody has priced yet, named in the lines below it.
186 /// The publication benchmark keeps an identical copy of this function.
187 fn writeCoverage(out: *std.Io.Writer, scope: []const u8, coverage: floor.Coverage) !void {
188 std.debug.assert(scope.len > 0);
189 switch (coverage) {
190 .complete => try out.print("profile coverage {s}: complete\n", .{scope}),
191 .partial => |names| {
192 std.debug.assert(names.len > 0);
193 try out.print("profile coverage {s}: partial, unpriced={d}\n", .{ scope, names.len });
194 for (names) |name| try out.print("profile unpriced {s}: {s}\n", .{ scope, name });
195 },
196 }
197 }
198
199 test "scan gate takes the lower of two middle samples" {
200 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
201 defer arena_state.deinit();
202 const arena = arena_state.allocator();
203 try std.testing.expectEqual(@as(u64, 7), try lowerMedian(arena, &.{7}));
204 try std.testing.expectEqual(@as(u64, 3), try lowerMedian(arena, &.{ 9, 3 }));
205 try std.testing.expectEqual(@as(u64, 5), try lowerMedian(arena, &.{ 9, 5, 1 }));
206 try std.testing.expectEqual(@as(u64, 5), try lowerMedian(arena, &.{ 1, 5, 9, 11 }));
207 }
208
209 test "scan gate leaves its samples in the order it was given" {
210 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
211 defer arena_state.deinit();
212 const arena = arena_state.allocator();
213 const samples = [_]u64{ 9, 5, 1 };
214 _ = try lowerMedian(arena, &samples);
215 try std.testing.expectEqualSlices(u64, &.{ 9, 5, 1 }, &samples);
216 }
217
218 test "scan gate withdraws its clock when a sample loses its count" {
219 var cycles = Cycles{};
220 var absent: Region = null;
221 cycles.record(&absent, 1000);
222 try std.testing.expect(!cycles.counted);
223 try std.testing.expectEqual(@as(?floor.Clock, null), cycles.clock());
224 }
225
226 test "scan gate derives a clock from the cycles and time it summed" {
227 var cycles = Cycles{ .total = 4_580_000, .elapsed_ns = 1_000_000 };
228 const observed = cycles.clock() orelse return error.TestUnexpectedResult;
229 try std.testing.expectEqual(@as(u32, 4_580_000), observed.kilohertz);
230 }
231
232 test "scan gate closes a counter the run never recorded" {
233 var region: Region = null;
234 close(®ion);
235 try std.testing.expectEqual(@as(Region, null), region);
236 }
237
238 test "scan gate leaves a recorded region empty so closing it again is safe" {
239 var cycles = Cycles{};
240 var region: Region = null;
241 cycles.record(®ion, 1000);
242 close(®ion);
243 try std.testing.expectEqual(@as(Region, null), region);
244 try std.testing.expect(!cycles.counted);
245 }
246
247 test "scan gate prints a floor a ratio and every unpriced name" {
248 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
249 defer arena_state.deinit();
250 var buffer: [2048]u8 = undefined;
251 var writer = std.Io.Writer.fixed(&buffer);
252 const samples = [_]u64{ 300_000, 310_000, 320_000 };
253 const defect = try write(
254 arena_state.allocator(),
255 &writer,
256 "accy.scan.iterate.scan",
257 .{ .lanes = 1024, .steps = 32 },
258 &samples,
259 .{ .kilohertz = 4_580_000 },
260 );
261 try std.testing.expect(!defect);
262 const printed = writer.buffered();
263 try std.testing.expect(std.mem.indexOf(u8, printed, "measured=310000ns") != null);
264 try std.testing.expect(std.mem.indexOf(u8, printed, "owner_issue=tiny-" ++ "1wgqc17y") != null);
265 try std.testing.expect(std.mem.indexOf(u8, printed, "partial, unpriced=3") != null);
266 try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, printed, "profile unpriced "));
267 }
268
269 test "scan gate reports a floor above its measurement as a defect" {
270 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
271 defer arena_state.deinit();
272 var buffer: [2048]u8 = undefined;
273 var writer = std.Io.Writer.fixed(&buffer);
274 const samples = [_]u64{1};
275 const defect = try write(
276 arena_state.allocator(),
277 &writer,
278 "accy.scan.iterate.scan",
279 .{ .lanes = 1024, .steps = 32 },
280 &samples,
281 .{ .kilohertz = 4_580_000 },
282 );
283 try std.testing.expect(defect);
284 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "profile defect") != null);
285 }
286
287 test "scan gate says so when no cycle counter was available" {
288 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
289 defer arena_state.deinit();
290 var buffer: [512]u8 = undefined;
291 var writer = std.Io.Writer.fixed(&buffer);
292 const samples = [_]u64{100};
293 const defect = try write(
294 arena_state.allocator(),
295 &writer,
296 "accy.scan.iterate.scan",
297 .{ .lanes = 8, .steps = 1 },
298 &samples,
299 null,
300 );
301 try std.testing.expect(!defect);
302 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "cycles uncounted") != null);
303 }