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(&region);
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(&region, 1000);
242     close(&region);
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 }