lib/accy/src/profiling/publication/suite.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const accy = @import("accy");
  3 const bench = @import("bench");
  4 const choir = @import("choir");
  5 const sys = @import("sys");
  6 const namespace = @import("root.zig");
  7 
  8 const coz = bench.coz;
  9 const floor = bench.floor;
 10 const perf = sys.perf;
 11 const floor_mod = namespace.floor;
 12 const options_mod = namespace.options;
 13 const operation = choir.product.operation;
 14 const revision = choir.product.revision;
 15 const publication = accy.preparation.publication;
 16 const Prepared = accy.preparation.pipeline.BackendPreparedModule;
 17 const PublicationStage = accy.choir.publication.Stage;
 18 
 19 const stage_count = floor_mod.stage_count;
 20 const maximum_samples = options_mod.maximum_samples;
 21 const record_bytes: u32 = 32 * 1024 * 1024;
 22 const workspace_bytes: u64 = 3328 * 1024 * 1024;
 23 const work_events: u32 = 256;
 24 const chain_elements: i64 = 4096;
 25 const chain_length: u32 = 64;
 26 const gap_ratio: u64 = 10;
 27 
 28 const context_limits = blk: {
 29     var limits = choir.ir.Context.Limits.testing;
 30     limits.operations.storage_bytes = 1024 * 1024;
 31     limits.operations.nested_bytes = 1024 * 1024;
 32     break :blk limits;
 33 };
 34 
 35 const configuration = operation.Configuration{
 36     .context = context_limits,
 37     .register = registerContext,
 38     .registration = .{ .name = "accy-publication-bench", .version = 1 },
 39     .codec = .{ .operations = 10000, .entities = 10000, .fields = 10000, .depth = 64 },
 40     .image = .{ .bytes = 16 * 1024 * 1024, .entities = 10000, .depth = 64 },
 41     .roots = 1,
 42     .gate_scratch = 128 * 1024 * 1024,
 43     .verify = choir.ir.verify.default_options,
 44 };
 45 
 46 const hardware_selectors = [_]perf.CounterSelector{.{ .hardware = .cycles }};
 47 const software_selectors = [_]perf.CounterSelector{
 48     .{ .software = .page_faults },
 49     .{ .software = .task_clock },
 50 };
 51 const counter_options = perf.CounterOptions{ .exclude_kernel = true, .exclude_hv = true };
 52 const HardwareRegion = perf.CounterSetRegion(hardware_selectors.len);
 53 const SoftwareRegion = perf.CounterSetRegion(software_selectors.len);
 54 
 55 const Interval = struct {
 56     ns: u64,
 57     cycles: ?u64 = null,
 58     page_faults: ?u64 = null,
 59     task_ns: ?u64 = null,
 60 };
 61 
 62 /// Elapsed time plus counter readings for a single timed region: page faults
 63 /// and task clock from software counters, and cycles from a hardware counter.
 64 /// The benchmark wraps each timed operation in one of these. The hardware
 65 /// counter opens after the software ones and closes before them, so the cycle
 66 /// count includes as little counter bookkeeping as possible.
 67 const Timer = struct {
 68     hardware: ?HardwareRegion,
 69     software: ?SoftwareRegion,
 70     start: i128,
 71 
 72     fn begin() Timer {
 73         const software = SoftwareRegion.start(&software_selectors, counter_options) catch null;
 74         const hardware = HardwareRegion.start(&hardware_selectors, counter_options) catch null;
 75         return .{ .hardware = hardware, .software = software, .start = bench.nowNs() };
 76     }
 77 
 78     fn end(self: *Timer) Interval {
 79         var interval = Interval{ .ns = bench.elapsedNs(self.start) };
 80         if (self.hardware) |*region| {
 81             defer region.deinit();
 82             if (region.stop() catch null) |counts| {
 83                 interval.cycles = complete(counts.find(hardware_selectors[0]));
 84             }
 85         }
 86         if (self.software) |*region| {
 87             defer region.deinit();
 88             if (region.stop() catch null) |counts| {
 89                 interval.page_faults = complete(counts.find(software_selectors[0]));
 90                 interval.task_ns = complete(counts.find(software_selectors[1]));
 91             }
 92         }
 93         self.hardware = null;
 94         self.software = null;
 95         return interval;
 96     }
 97 };
 98 
 99 fn complete(count: ?perf.CountResult) ?u64 {
100     const value = count orelse return null;
101     if (value.runningCoverage() != .complete) return null;
102     return value.value;
103 }
104 
105 const Series = struct {
106     ns: [maximum_samples]u64 = undefined,
107     page_faults: [maximum_samples]u64 = undefined,
108     task_ns: [maximum_samples]u64 = undefined,
109     len: u8 = 0,
110     cycles: u64 = 0,
111     elapsed_ns: u64 = 0,
112     cycles_counted: bool = true,
113     software_counted: bool = true,
114 
115     fn record(self: *Series, interval: Interval) void {
116         std.debug.assert(self.len < maximum_samples);
117         std.debug.assert(interval.ns > 0);
118         self.ns[self.len] = interval.ns;
119         self.page_faults[self.len] = interval.page_faults orelse 0;
120         self.task_ns[self.len] = interval.task_ns orelse 0;
121         self.len += 1;
122         self.elapsed_ns += interval.ns;
123         if (interval.cycles) |cycles| self.cycles += cycles else self.cycles_counted = false;
124         if (interval.page_faults == null) self.software_counted = false;
125         if (interval.task_ns == null) self.software_counted = false;
126     }
127 
128     fn clock(self: *const Series) ?floor.Clock {
129         if (!self.cycles_counted) return null;
130         return floor.Clock.observed(self.cycles, self.elapsed_ns) catch null;
131     }
132 };
133 
134 /// Returns the lower median of `values`, the smaller of the two middle samples,
135 /// so each row compares the faster middle sample and never a slower one against
136 /// the floor. `values` must hold 1 to 64 samples, and the sort runs on a stack
137 /// copy. The scan benchmark keeps its own copy of this function.
138 fn lowerMedian(values: []const u64) u64 {
139     std.debug.assert(values.len > 0);
140     std.debug.assert(values.len <= maximum_samples);
141     var sorted: [maximum_samples]u64 = undefined;
142     @memcpy(sorted[0..values.len], values);
143     std.mem.sort(u64, sorted[0..values.len], {}, std.sort.asc(u64));
144     return sorted[(values.len - 1) / 2];
145 }
146 
147 const Intervals = struct {
148     cold: Interval,
149     warm: Interval,
150     retained: Interval,
151     identity: Interval,
152     copy: Interval,
153 };
154 
155 const Samples = struct {
156     cold: Series = .{},
157     warm: Series = .{},
158     retained: Series = .{},
159     identity: Series = .{},
160     copy: Series = .{},
161 
162     fn record(self: *Samples, intervals: Intervals) void {
163         self.cold.record(intervals.cold);
164         self.warm.record(intervals.warm);
165         self.retained.record(intervals.retained);
166         self.identity.record(intervals.identity);
167         self.copy.record(intervals.copy);
168     }
169 };
170 
171 /// The records embedded by one target stage record, counted by walking its
172 /// chain of predecessors for the benchmark to print as facts about the final
173 /// record. `bytes` is the total a compile writes to describe that chain, so a
174 /// change to the record's shape moves it.
175 const Closure = struct {
176     records: u32 = 0,
177     bytes: u64 = 0,
178 };
179 
180 const Observation = struct {
181     intervals: Intervals,
182     cold: [stage_count]floor_mod.Stage,
183     warm: [stage_count]floor_mod.Stage,
184     retained: [stage_count]floor_mod.Stage,
185     target: floor_mod.Sealed,
186     closure: Closure,
187 };
188 
189 const Preparation = struct {
190     prepared: *Prepared,
191     report: publication.PreparationReport,
192 
193     fn deinit(self: *Preparation) void {
194         self.prepared.deinit();
195         self.report.deinit();
196     }
197 
198     fn revisionAt(self: *const Preparation, index: usize) *const revision.Revision {
199         std.debug.assert(index < stage_count);
200         std.debug.assert(self.report.completed == stage_count);
201         return self.report.stages[index].record();
202     }
203 };
204 
205 const Summary = struct {
206     family: []const u8 = "memory",
207     name: []const u8,
208     metric: []const u8,
209     unit: []const u8,
210     aggregation: []const u8 = "deterministic",
211     count: u32 = 1,
212     value: u64,
213 };
214 
215 const RowInput = struct {
216     scope: []const u8,
217     series: *const Series,
218     derivation: floor.Derivation,
219     /// The cause measured for a wide gap on this row, or null when no run has
220     /// named one, carried so a wide gate can print it. No row sets it today, so
221     /// a publication gate never names a cause.
222     gap: ?floor.Gap = null,
223 };
224 
225 /// Runs the warmup observations, then the measured samples, and prints the
226 /// record facts followed by one gate row per timed operation. The command calls
227 /// this to run the whole benchmark. Every sample must produce the same record
228 /// lengths as the first, else the run fails with
229 /// `error.NondeterministicRecords`. After printing every row, the run fails
230 /// with `error.FloorAboveMeasurement` when any floor exceeded its measurement.
231 pub fn run(
232     allocator: std.mem.Allocator,
233     out: *std.Io.Writer,
234     options: options_mod.Options,
235 ) !void {
236     std.debug.assert(options.samples > 0);
237     std.debug.assert(options.samples <= maximum_samples);
238     _ = try choir.product.compiler.manifest();
239     const destination = try allocator.alloc(u8, record_bytes);
240     defer allocator.free(destination);
241     @memset(destination, 0);
242     for (0..options.warmup) |_| _ = try observe(allocator, destination);
243     var samples = Samples{};
244     const first = try observe(allocator, destination);
245     samples.record(first.intervals);
246     coz.progressNamed("accy.publication.sample");
247     for (1..options.samples) |_| {
248         const next = try observe(allocator, destination);
249         try requireSameShape(&first, &next);
250         samples.record(next.intervals);
251         coz.progressNamed("accy.publication.sample");
252     }
253     try writeFacts(out, &first);
254     try writeRows(out, &samples, &first);
255 }
256 
257 fn observe(allocator: std.mem.Allocator, destination: []u8) !Observation {
258     var observation: Observation = undefined;
259     const intervals = &observation.intervals;
260     var cold = try prepareDraft(allocator, null, &intervals.cold, "cold");
261     defer cold.deinit();
262     var warm = try prepareDraft(allocator, &cold, &intervals.warm, "warm");
263     defer warm.deinit();
264     const source = cold.report.source orelse return error.MissingSemanticSource;
265     const input = publication.SemanticInput{ .retained = source };
266     var retained = try prepareTimed(allocator, input, &cold, &intervals.retained, "retained");
267     defer retained.deinit();
268     var reference = try prepareDraft(allocator, null, null, "reference");
269     defer reference.deinit();
270     try describe(.cold, &cold, null, &observation.cold);
271     try describe(.warm, &warm, &cold, &observation.warm);
272     try describe(.retained, &retained, &cold, &observation.retained);
273     const target = cold.revisionAt(stage_count - 1);
274     intervals.identity = try compareRecords(target, reference.revisionAt(stage_count - 1));
275     intervals.copy = try copyRecord(target, destination);
276     observation.target = observation.cold[stage_count - 1].sealed;
277     observation.closure = try closureOf(target.view().semantic_record);
278     return observation;
279 }
280 
281 fn prepareDraft(
282     allocator: std.mem.Allocator,
283     candidate: ?*const Preparation,
284     interval: ?*Interval,
285     comptime label: []const u8,
286 ) !Preparation {
287     const module = try buildChain(allocator);
288     defer module.deinit();
289     return prepareTimed(allocator, .{ .draft = module }, candidate, interval, label);
290 }
291 
292 fn prepareTimed(
293     allocator: std.mem.Allocator,
294     input: publication.SemanticInput,
295     candidate: ?*const Preparation,
296     interval: ?*Interval,
297     comptime label: []const u8,
298 ) !Preparation {
299     var report = publication.PreparationReport{};
300     errdefer report.deinit();
301     const current = request(if (candidate) |item| item.prepared else null);
302     const scope = coz.scope("accy.publication." ++ label);
303     var timer = Timer.begin();
304     const result = publication.prepare(allocator, input, current, &report, configuration);
305     const measured = timer.end();
306     scope.end();
307     const prepared = try result;
308     if (interval) |output| output.* = measured;
309     return .{ .prepared = prepared, .report = report };
310 }
311 
312 fn compareRecords(left: *const revision.Revision, right: *const revision.Revision) !Interval {
313     const left_inputs = left.view().exact.inputs;
314     const right_inputs = right.view().exact.inputs;
315     if (left_inputs.ptr == right_inputs.ptr) return error.SharedTargetRecord;
316     const scope = coz.scope("accy.publication.target.identity");
317     var timer = Timer.begin();
318     const equal = left.eql(right);
319     const measured = timer.end();
320     scope.end();
321     if (!equal) return error.TargetRecordsDiffer;
322     return measured;
323 }
324 
325 fn copyRecord(target: *const revision.Revision, destination: []u8) !Interval {
326     const view = target.view();
327     if (view.semantic_record.len > destination.len) return error.RecordLimit;
328     const scope = coz.scope("accy.publication.target.copy");
329     var timer = Timer.begin();
330     const result = revision.record.encodeExactInto(destination, view.exact);
331     const measured = timer.end();
332     scope.end();
333     const written = try result;
334     if (!std.mem.eql(u8, written, view.semantic_record)) return error.RecordCopyDiffers;
335     return measured;
336 }
337 
338 fn describe(
339     kind: floor_mod.Route,
340     current: *const Preparation,
341     candidate: ?*const Preparation,
342     output: *[stage_count]floor_mod.Stage,
343 ) !void {
344     for (output, 0..) |*stage, index| {
345         const execution = &current.report.stages[index];
346         const shared = sharesDependency(current, candidate, index);
347         stage.* = .{
348             .sealed = try sealedView(execution.record()),
349             .execution = switch (execution.*) {
350                 .cold => .cold,
351                 .warm => if (shared) .reused_shared else .reused,
352             },
353         };
354     }
355     if (!floor_mod.matches(kind, output)) return error.UnexpectedStageExecution;
356 }
357 
358 /// Returns true when the kept predecessor record of stage `index` and the
359 /// candidate's stored record are the same bytes at the same address, the test
360 /// `Draft.eqlInputs` makes, so the benchmark tells whether a reused stage's
361 /// predecessor was shared or compared in full. The first stage, or a stage with
362 /// no candidate, never shares.
363 fn sharesDependency(
364     current: *const Preparation,
365     candidate: ?*const Preparation,
366     index: usize,
367 ) bool {
368     if (index == 0) return false;
369     const prior = candidate orelse return false;
370     const retained = current.revisionAt(index - 1).view().semantic_record;
371     const stored = prior.revisionAt(index - 1).view().semantic_record;
372     return retained.ptr == stored.ptr;
373 }
374 
375 fn sealedView(item: *const revision.Revision) !floor_mod.Sealed {
376     const view = item.view();
377     const inputs = view.exact.inputs;
378     const decoded = item.inputs();
379     var dependency: []const u8 = &.{};
380     var dependencies = decoded.dependencies.iterator();
381     if (try dependencies.next()) |first| dependency = first.exact;
382     if (try dependencies.next() != null) return error.UnexpectedDependencies;
383     var offset: usize = 0;
384     if (dependency.len > 0) {
385         const start = @intFromPtr(dependency.ptr);
386         if (start < @intFromPtr(inputs.ptr)) return error.UnexpectedDependencies;
387         offset = start - @intFromPtr(inputs.ptr);
388     }
389     if (offset + dependency.len > inputs.len) return error.UnexpectedDependencies;
390     if (dependency.len > 0 and offset == 0) return error.UnexpectedDependencies;
391     if (view.exact.address.len == 0) return error.EmptyRecordRegion;
392     if (inputs.len == 0) return error.EmptyRecordRegion;
393     if (view.exact.image.len == 0) return error.EmptyRecordRegion;
394     if (view.compiler_manifest.len == 0) return error.EmptyRecordRegion;
395     if (view.semantic_record.len >= view.compiler_manifest.len) {
396         return error.UnexpectedRecordShape;
397     }
398     return .{
399         .address = @intCast(view.exact.address.len),
400         .inputs = @intCast(inputs.len),
401         .image = @intCast(view.exact.image.len),
402         .record = @intCast(view.semantic_record.len),
403         .manifest = @intCast(view.compiler_manifest.len),
404         .dependency = @intCast(dependency.len),
405         .dependency_offset = @intCast(offset),
406     };
407 }
408 
409 fn closureOf(bytes: []const u8) !Closure {
410     var result = Closure{};
411     var current = bytes;
412     for (0..stage_count) |_| {
413         const exact = try revision.record.decodeExact(current);
414         const inputs = try revision.record.decodeInputs(exact.inputs);
415         result.records += 1;
416         result.bytes += current.len;
417         var dependencies = inputs.dependencies.iterator();
418         const dependency = try dependencies.next() orelse return result;
419         if (try dependencies.next() != null) return error.UnexpectedDependencies;
420         current = dependency.exact;
421     }
422     return error.UnexpectedDependencies;
423 }
424 
425 fn requireSameShape(first: *const Observation, next: *const Observation) !void {
426     if (!std.meta.eql(first.cold, next.cold)) return error.NondeterministicRecords;
427     if (!std.meta.eql(first.warm, next.warm)) return error.NondeterministicRecords;
428     if (!std.meta.eql(first.retained, next.retained)) return error.NondeterministicRecords;
429     if (!std.meta.eql(first.closure, next.closure)) return error.NondeterministicRecords;
430 }
431 
432 fn writeFacts(out: *std.Io.Writer, observation: *const Observation) !void {
433     const target = observation.target;
434     try writeSummary(out, .{
435         .name = "accy.publication",
436         .metric = "compiler_manifest_bytes",
437         .unit = "bytes",
438         .value = target.manifest,
439     });
440     for (observation.cold, 0..) |stage, index| {
441         const tag = @tagName(@as(PublicationStage, @fromBackingInt(@intCast(index))));
442         var name: [64]u8 = undefined;
443         const stage_scope = try std.fmt.bufPrint(&name, "accy.publication.{s}", .{tag});
444         try writeSummary(out, .{
445             .name = stage_scope,
446             .metric = "record_bytes",
447             .unit = "bytes",
448             .value = stage.sealed.record,
449         });
450     }
451     try writeClosure(out, observation);
452     try writeRequired(out, "accy.publication.cold", &observation.cold);
453     try writeRequired(out, "accy.publication.warm", &observation.warm);
454     try writeRequired(out, "accy.publication.retained", &observation.retained);
455 }
456 
457 fn writeClosure(out: *std.Io.Writer, observation: *const Observation) !void {
458     const embedded = observation.closure;
459     const scope = "accy.publication.target";
460     std.debug.assert(embedded.records <= stage_count);
461     std.debug.assert(observation.target.record <= record_bytes);
462     const facts = [_]Summary{
463         .{ .name = scope, .metric = "closure_records", .unit = "count", .value = embedded.records },
464         .{
465             .name = scope,
466             .metric = "closure_bytes",
467             .unit = "bytes",
468             .value = embedded.bytes,
469         },
470         .{
471             .name = scope,
472             .metric = "record_headroom_bytes",
473             .unit = "bytes",
474             .value = record_bytes - observation.target.record,
475         },
476     };
477     for (facts) |fact| try writeSummary(out, fact);
478 }
479 
480 fn writeRequired(
481     out: *std.Io.Writer,
482     scope: []const u8,
483     stages: *const [stage_count]floor_mod.Stage,
484 ) !void {
485     const totals = floor_mod.required(stages);
486     try writeSummary(out, .{
487         .name = scope,
488         .metric = "required_copy_bytes",
489         .unit = "bytes",
490         .value = totals.copy_bytes,
491     });
492     try writeSummary(out, .{
493         .name = scope,
494         .metric = "required_compare_bytes",
495         .unit = "bytes",
496         .value = totals.compare_bytes,
497     });
498 }
499 
500 fn writeSummary(out: *std.Io.Writer, summary: Summary) !void {
501     std.debug.assert(summary.name.len > 0);
502     std.debug.assert(summary.count > 0);
503     try out.print(
504         "{{\"schema\":\"{s}\",\"event\":\"summary\",\"family\":\"{s}\"," ++
505             "\"metric\":\"{s}\",\"unit\":\"{s}\",\"aggregation\":\"{s}\"," ++
506             "\"name\":\"{s}\",\"count\":{d},\"value\":{d}}}\n",
507         .{
508             floor.metric_schema, summary.family, summary.metric, summary.unit,
509             summary.aggregation, summary.name,   summary.count,  summary.value,
510         },
511     );
512 }
513 
514 fn writeRows(
515     out: *std.Io.Writer,
516     samples: *const Samples,
517     observation: *const Observation,
518 ) !void {
519     var entries: [5]floor_mod.Entries = @splat(.{});
520     const rows = [_]RowInput{ .{
521         .scope = "accy.publication.cold",
522         .series = &samples.cold,
523         .derivation = floor_mod.route(.cold, &observation.cold, &entries[0]),
524     }, .{
525         .scope = "accy.publication.warm",
526         .series = &samples.warm,
527         .derivation = floor_mod.route(.warm, &observation.warm, &entries[1]),
528     }, .{
529         .scope = "accy.publication.retained",
530         .series = &samples.retained,
531         .derivation = floor_mod.route(.retained, &observation.retained, &entries[2]),
532     }, .{
533         .scope = "accy.publication.target.identity",
534         .series = &samples.identity,
535         .derivation = floor_mod.identity(observation.target, &entries[3]),
536     }, .{
537         .scope = "accy.publication.target.copy",
538         .series = &samples.copy,
539         .derivation = floor_mod.copy(observation.target, &entries[4]),
540     } };
541     var defects: u8 = 0;
542     for (rows) |row| defects += @intFromBool(try writeRow(out, row));
543     if (defects == 0) return;
544     try out.flush();
545     return error.FloorAboveMeasurement;
546 }
547 
548 /// Prints one gate with its coverage, then a clock metric when every sample
549 /// counted its cycles, then median page faults and task clock when the software
550 /// counters worked. The benchmark calls this once per timed row. The function
551 /// returns true, after printing a `profile defect` line, when the floor exceeds
552 /// the measurement. The gate carries the row's gap only when the ratio
553 /// exceeds 10.
554 fn writeRow(out: *std.Io.Writer, row: RowInput) !bool {
555     const series = row.series;
556     const estimate = try row.derivation.estimate(null);
557     const floor_ns = try estimate.totalNs();
558     const measured_ns = lowerMedian(series.ns[0..series.len]);
559     std.debug.assert(floor_ns > 0);
560     std.debug.assert(measured_ns > 0);
561     const exceeded = floor.ceilingRatio(measured_ns, floor_ns) > gap_ratio;
562     const gate = floor.Gate{
563         .scope = row.scope,
564         .floor_ns = floor_ns,
565         .measured_ns = measured_ns,
566         .gap = if (exceeded) row.gap else null,
567     };
568     try out.print("{f}", .{gate});
569     try writeCoverage(out, row.scope, row.derivation.coverage);
570     if (series.clock()) |observed| {
571         const metric = floor.Row{
572             .scope = row.scope,
573             .estimate = estimate,
574             .measured_ns = measured_ns,
575             .clock = observed,
576             .gap = gate.gap,
577         };
578         try metric.writeMetric(out);
579     }
580     if (series.software_counted) try writeKernelShare(out, row.scope, series);
581     if (floor_ns <= measured_ns) return false;
582     try out.print("profile defect {s}: floor={d}ns measured={d}ns\n", .{
583         row.scope, floor_ns, measured_ns,
584     });
585     return true;
586 }
587 
588 /// States whether the floor covers all of the row's work, and lists each
589 /// unpriced piece on its own line when unpriced pieces remain. `writeRow` calls
590 /// this function after each gate line. A wide ratio then reads as work left
591 /// unpriced, named in the lines below it. The scan benchmark keeps an identical
592 /// copy of this function.
593 fn writeCoverage(out: *std.Io.Writer, scope: []const u8, coverage: floor.Coverage) !void {
594     switch (coverage) {
595         .complete => try out.print("profile coverage {s}: complete\n", .{scope}),
596         .partial => |names| {
597             try out.print("profile coverage {s}: partial, unpriced={d}\n", .{ scope, names.len });
598             for (names) |name| try out.print("profile unpriced {s}: {s}\n", .{ scope, name });
599         },
600     }
601 }
602 
603 fn writeKernelShare(out: *std.Io.Writer, scope: []const u8, series: *const Series) !void {
604     std.debug.assert(series.software_counted);
605     std.debug.assert(series.len > 0);
606     try writeSummary(out, .{
607         .name = scope,
608         .metric = "page_faults",
609         .unit = "count",
610         .aggregation = "median",
611         .count = series.len,
612         .value = lowerMedian(series.page_faults[0..series.len]),
613     });
614     try writeSummary(out, .{
615         .family = "timing",
616         .name = scope,
617         .metric = "task_clock_ns",
618         .unit = "ns",
619         .aggregation = "median",
620         .count = series.len,
621         .value = lowerMedian(series.task_ns[0..series.len]),
622     });
623 }
624 
625 fn request(candidate: ?*const Prepared) publication.PreparationRequest {
626     return .{
627         .source = "accy-publication-bench",
628         .work = .{
629             .allowance = revision.WorkVector.uniform(std.math.maxInt(u64)),
630             .workspace = workspace_bytes,
631             .events = work_events,
632         },
633         .record_bytes = record_bytes,
634         .candidate = candidate,
635     };
636 }
637 
638 fn buildChain(allocator: std.mem.Allocator) !*accy.choir.SemanticModule {
639     var builder = try accy.choir.SemanticBuilder.init(allocator, .standard);
640     defer builder.deinit();
641     const tensor = try builder.tensor(.f32, &.{chain_elements});
642     var function = try builder.beginFunction(
643         "accy_publication_chain",
644         &.{ tensor, tensor },
645         &.{tensor},
646     );
647     var current = function.parameter(0);
648     const operand = function.parameter(1);
649     for (0..chain_length) |index| {
650         current = switch (index % 4) {
651             0 => try function.add(current, operand),
652             1 => try function.mul(current, operand),
653             2 => try function.sub(current, operand),
654             else => try function.max(current, operand),
655         };
656     }
657     try function.return_(&.{current});
658     try function.finish();
659     return builder.finish();
660 }
661 
662 fn registerContext(context: *choir.ir.Context) !void {
663     try choir.dialects.registerChoirDialect(context);
664     try accy.choir.registerAccyDialect(context);
665     try choir.backends.gpu.registerTargetDialects(context);
666 }