@cosyte/bench 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import 'perf_hooks';
7
7
  import { fileURLToPath, pathToFileURL } from 'url';
8
8
  import { gzipSync } from 'zlib';
9
9
  import { spawn } from 'child_process';
10
+ import 'v8';
10
11
 
11
12
  function readCgroupCpuMax(path = "/sys/fs/cgroup/cpu.max") {
12
13
  try {
@@ -59,6 +60,7 @@ var BENCH_CONTRACT = Object.freeze({
59
60
  ALLOC_WINDOW_MS: 100,
60
61
  ALLOC_SEMI_SPACE_MIB: 64,
61
62
  PEAK_CALLS: 20,
63
+ FLOOR_INPUT_BYTES: 64 * 1024,
62
64
  LONG_RUN_ROUNDS: 30,
63
65
  LONG_RUN_SETTLE_ROUNDS: 5,
64
66
  LONG_RUN_ROUND_MS: 100,
@@ -115,11 +117,12 @@ function runNode(entry, args, execArgv, timeoutMs, execPath = process.execPath)
115
117
  function flagsFor(job) {
116
118
  if (job.mode === "timing") return [];
117
119
  const memory = ["--expose-gc", "--no-concurrent-array-buffer-sweeping"];
118
- if (job.mode === "heap") return memory;
120
+ if (job.mode === "heap" || job.mode === "floor") return memory;
119
121
  const mib = String(BENCH_CONTRACT.ALLOC_SEMI_SPACE_MIB);
120
122
  return [...memory, `--min-semi-space-size=${mib}`, `--max-semi-space-size=${mib}`];
121
123
  }
122
124
  function describe(job) {
125
+ if (job.mode === "floor") return "floor job";
123
126
  return `${job.mode} job for "${job.case.benchmark} @ ${job.case.size}"`;
124
127
  }
125
128
  async function runInChild(job, options = {}) {
@@ -554,18 +557,55 @@ async function calibrateComparison(options) {
554
557
  }
555
558
  return summarizeCalibration(options.inject ?? 0, runs);
556
559
  }
560
+ function aboveFloor(peak, floor) {
561
+ if (peak.status !== "measured" || floor === null || floor.status !== "measured") return peak;
562
+ const less = (value, base) => value === void 0 || base === void 0 ? void 0 : Math.max(0, value - base);
563
+ const out = {
564
+ ...peak,
565
+ peakHeapAboveFloorBytes: less(peak.peakHeapBytes, floor.medianPeakHeapBytes),
566
+ medianPeakHeapAboveFloorBytes: less(peak.medianPeakHeapBytes, floor.medianPeakHeapBytes),
567
+ peakExternalAboveFloorBytes: less(peak.peakExternalBytes, floor.medianPeakExternalBytes),
568
+ medianPeakExternalAboveFloorBytes: less(
569
+ peak.medianPeakExternalBytes,
570
+ floor.medianPeakExternalBytes
571
+ ),
572
+ peakTotalAboveFloorBytes: less(peak.peakTotalBytes, floor.medianPeakTotalBytes),
573
+ medianPeakTotalAboveFloorBytes: less(peak.medianPeakTotalBytes, floor.medianPeakTotalBytes)
574
+ };
575
+ return Object.fromEntries(
576
+ Object.entries(out).filter(([, v]) => v !== void 0)
577
+ );
578
+ }
557
579
 
558
580
  // src/reading.ts
581
+ var WrongKind = class extends Error {
582
+ };
559
583
  function expectTiming(result) {
560
584
  if (result.mode !== "timing")
561
- throw new Error("@cosyte/bench: a timing job returned another kind");
585
+ throw new WrongKind("@cosyte/bench: a timing job returned another kind");
562
586
  return result;
563
587
  }
564
588
  function expectMemory(result) {
565
- if (result.mode === "timing")
566
- throw new Error("@cosyte/bench: a memory job returned a timing result");
589
+ if (result.mode === "timing" || result.mode === "floor")
590
+ throw new WrongKind("@cosyte/bench: a memory job returned another kind");
567
591
  return result;
568
592
  }
593
+ function expectFloor(result) {
594
+ if (result.mode !== "floor")
595
+ throw new WrongKind("@cosyte/bench: a floor job returned another kind");
596
+ return result.peak;
597
+ }
598
+ function firstLine(error) {
599
+ const text = error instanceof Error ? error.message : String(error);
600
+ return text.split("\n")[0];
601
+ }
602
+ function budgetOf(c, p) {
603
+ const raw = p.peakTotalBytes ?? p.peakHeapBytes;
604
+ if (c.bytesPerPass === null) return raw;
605
+ const perInput = c.bytesPerPass / c.inputsPerPass;
606
+ if (perInput >= BENCH_CONTRACT.FLOOR_INPUT_BYTES) return raw;
607
+ return p.medianPeakTotalAboveFloorBytes ?? raw;
608
+ }
569
609
  function summarizeThroughput(first, rest) {
570
610
  const measured = [];
571
611
  for (const t of [first, ...rest]) {
@@ -601,37 +641,56 @@ async function readBuild(options) {
601
641
  if (!Number.isInteger(processes) || processes < 1) {
602
642
  throw new TypeError("@cosyte/bench: processes must be a positive integer");
603
643
  }
644
+ const memory = options.memory ?? true;
604
645
  const base = { suite: options.suiteFile, target: options.target };
605
646
  const listed = listCases(options.suite, options.only);
606
647
  const cases = [];
648
+ const failures = [];
649
+ const fail2 = (phase, c, error) => {
650
+ if (error instanceof WrongKind) throw error;
651
+ const message = firstLine(error);
652
+ failures.push({ phase, benchmark: c?.benchmark ?? null, size: c?.size ?? null, message });
653
+ const what = c === null ? "the memory floor" : `${phase} of ${c.benchmark} @ ${c.size}`;
654
+ options.onProgress?.(
655
+ `could not measure ${what}: ${error instanceof Error ? error.message : String(error)}`
656
+ );
657
+ };
607
658
  for (const name of new Set(listed.map((c) => c.benchmark))) {
608
659
  const timed = [];
609
660
  for (const c of listed.filter((l) => l.benchmark === name)) {
610
661
  const timing = async (latency) => expectTiming(await runJob({ mode: "timing", ...base, case: c, latency, inject: 0 }));
611
- const first = await timing(true);
612
- const rest = [];
613
- for (let p = 1; p < processes; p++) rest.push(await timing(false));
614
- timed.push({ c, first, throughput: summarizeThroughput(first, rest) });
662
+ try {
663
+ const first = await timing(true);
664
+ const rest = [];
665
+ for (let p = 1; p < processes; p++) rest.push(await timing(false));
666
+ timed.push({ c, first, throughput: summarizeThroughput(first, rest) });
667
+ } catch (error) {
668
+ fail2("timing", c, error);
669
+ }
615
670
  }
616
671
  const largest = timed.filter((t) => longRunFits(t.throughput, t.first.inputsPerPass)).pop();
617
672
  for (const { c, first, throughput } of timed) {
618
673
  let allocations = null;
619
674
  let peak = null;
620
675
  let longRun = null;
621
- if (options.memory ?? true) {
622
- allocations = expectMemory(await runJob({ mode: "alloc", ...base, case: c })).allocations;
623
- const { benchmark } = findCase(options.suite, c);
624
- const longRunSize = benchmark.longRun ?? benchmark.sizes[0].label;
625
- const heap = expectMemory(
626
- await runJob({
627
- mode: "heap",
628
- ...base,
629
- case: c,
630
- longRun: c.size === longRunSize || c.size === largest?.c.size
631
- })
632
- );
633
- peak = heap.peak;
634
- longRun = heap.longRun;
676
+ if (memory) {
677
+ try {
678
+ allocations = expectMemory(await runJob({ mode: "alloc", ...base, case: c })).allocations;
679
+ const { benchmark } = findCase(options.suite, c);
680
+ const longRunSize = benchmark.longRun ?? benchmark.sizes[0].label;
681
+ const heap = expectMemory(
682
+ await runJob({
683
+ mode: "heap",
684
+ ...base,
685
+ case: c,
686
+ longRun: c.size === longRunSize || c.size === largest?.c.size
687
+ })
688
+ );
689
+ peak = heap.peak;
690
+ longRun = heap.longRun;
691
+ } catch (error) {
692
+ fail2("memory", c, error);
693
+ }
635
694
  }
636
695
  cases.push({
637
696
  ...c,
@@ -646,8 +705,24 @@ async function readBuild(options) {
646
705
  options.onProgress?.(`read ${c.benchmark} @ ${c.size}`);
647
706
  }
648
707
  }
708
+ let memoryFloor = null;
709
+ if (memory) {
710
+ try {
711
+ memoryFloor = expectFloor(await runJob({ mode: "floor" }));
712
+ } catch (error) {
713
+ fail2("floor", null, error);
714
+ }
715
+ }
649
716
  const importCost = options.importCost ?? true ? await options.measureImport(options.target) : null;
650
- return { processes, cases, importCost };
717
+ return {
718
+ processes,
719
+ cases: cases.map(
720
+ (c) => c.peak === null ? c : { ...c, peak: aboveFloor(c.peak, memoryFloor) }
721
+ ),
722
+ memoryFloor,
723
+ failures,
724
+ importCost
725
+ };
651
726
  }
652
727
 
653
728
  // src/report.ts
@@ -732,10 +807,19 @@ function renderMarkdown(doc) {
732
807
  const r = doc.reading;
733
808
  lines.push(...header(doc, "Reading"));
734
809
  lines.push(
735
- `Throughput is the median of ${String(r.processes)} fresh processes. "Large-case plan" marks a case whose calls each outlast a warmup batch. Latency is per call, nearest rank. Memory: allocations per operation ("collections counted" when every window collected), the peak of one call above what was in use before it (the V8 heap, external memory such as Buffers, and the two together, which a memory budget reads), and whether the settled heap stays flat over a long run.`,
736
- "",
737
- "| Benchmark | Size | Per op | Ops/s | Bytes/s | p50 | p95 | p99 | Alloc/op | Peak heap | Peak external | Peak total | Long run |",
738
- "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
810
+ `Throughput is the median of ${String(r.processes)} fresh processes. "Large-case plan" marks a case whose calls each outlast a warmup batch. Latency is per call, nearest rank. Memory: allocations per operation ("collections counted" when every window collected), the peak of one call above what was in use before it (the V8 heap, external memory such as Buffers, and the two together), the total above the empty-call floor, the peak a memory budget reads (the median call's total above the floor for an input under ${bytes(BENCH_CONTRACT.FLOOR_INPUT_BYTES)}, the highest raw total for any other), and whether the settled heap stays flat over a long run.`,
811
+ ""
812
+ );
813
+ const f = r.memoryFloor;
814
+ if (f !== void 0 && f !== null) {
815
+ lines.push(
816
+ f.status === "measured" ? `Empty-call floor, what the peak probe itself costs: ${bytes(f.medianPeakHeapBytes)} heap, ${f.medianPeakTotalBytes === void 0 ? "n/a" : bytes(f.medianPeakTotalBytes)} total (the median of ${String(f.calls)} calls).` : `Empty-call floor: skipped (${f.reason}).`,
817
+ ""
818
+ );
819
+ }
820
+ lines.push(
821
+ "| Benchmark | Size | Per op | Ops/s | Bytes/s | p50 | p95 | p99 | Alloc/op | Peak heap | Peak external | Peak total | Above floor | Budget reads | Long run |",
822
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
739
823
  );
740
824
  for (const c of r.cases) {
741
825
  const t = c.throughput;
@@ -749,13 +833,30 @@ function renderMarkdown(doc) {
749
833
  const alloc = a === null ? "n/a" : a.status === "measured" ? `${bytes(a.heapBytesPerOp + a.externalBytesPerOp)}${a.method === "gc-accounted" ? " (collections counted)" : ""}` : `skipped (${a.reason})`;
750
834
  const p = c.peak;
751
835
  const optional = (n) => n === void 0 ? "n/a" : bytes(n);
752
- const peak = p === null ? ["n/a", "n/a", "n/a"] : p.status === "measured" ? [bytes(p.peakHeapBytes), optional(p.peakExternalBytes), optional(p.peakTotalBytes)] : Array.from({ length: 3 }, () => `skipped (${p.reason})`);
836
+ const peak = p === null ? ["n/a", "n/a", "n/a", "n/a", "n/a"] : p.status === "measured" ? [
837
+ bytes(p.peakHeapBytes),
838
+ optional(p.peakExternalBytes),
839
+ optional(p.peakTotalBytes),
840
+ optional(p.peakTotalAboveFloorBytes),
841
+ bytes(budgetOf(c, p))
842
+ ] : Array.from({ length: 5 }, () => `skipped (${p.reason})`);
753
843
  const lr = c.longRun;
754
844
  const long = lr === null ? "" : lr.status === "measured" ? `${lr.verdict} (${bytes(lr.growthBytes)} over ${String(lr.ops)} ops, ${String(lr.slopeBytesPerOp)} B/op)` : `skipped (${lr.reason})`;
755
845
  lines.push(
756
846
  `| ${c.benchmark} | ${c.size} | ${perOp} | ${ops} | ${bps} | ${lat.join(" | ")} | ${alloc} | ${peak.join(" | ")} | ${long} |`
757
847
  );
758
848
  }
849
+ const failures = r.failures ?? [];
850
+ if (failures.length > 0) {
851
+ lines.push(
852
+ "",
853
+ `Could not measure ${String(failures.length)} part(s) of the run; every other case above was measured and written:`,
854
+ "",
855
+ ...failures.map(
856
+ (x) => x.benchmark === null ? `- the empty-call floor: ${x.message}` : `- ${x.phase} of ${x.benchmark} @ ${String(x.size)}: ${x.message}`
857
+ )
858
+ );
859
+ }
759
860
  if (r.importCost !== null) {
760
861
  const i = r.importCost;
761
862
  lines.push(
@@ -1000,12 +1101,46 @@ var RESULTS_SCHEMA = {
1000
1101
  }
1001
1102
  }
1002
1103
  },
1104
+ peak: measuredOrSkipped({
1105
+ type: "object",
1106
+ required: ["status", "calls", "peakHeapBytes", "medianPeakHeapBytes"],
1107
+ properties: {
1108
+ status: { const: "measured" },
1109
+ calls: int,
1110
+ peakHeapBytes: num,
1111
+ medianPeakHeapBytes: num,
1112
+ // Added within version 1, so not required.
1113
+ peakExternalBytes: num,
1114
+ medianPeakExternalBytes: num,
1115
+ peakTotalBytes: num,
1116
+ medianPeakTotalBytes: num,
1117
+ peakHeapAboveFloorBytes: num,
1118
+ medianPeakHeapAboveFloorBytes: num,
1119
+ peakExternalAboveFloorBytes: num,
1120
+ medianPeakExternalAboveFloorBytes: num,
1121
+ peakTotalAboveFloorBytes: num,
1122
+ medianPeakTotalAboveFloorBytes: num
1123
+ }
1124
+ }),
1125
+ failure: {
1126
+ type: "object",
1127
+ required: ["phase", "benchmark", "size", "message"],
1128
+ properties: {
1129
+ phase: { enum: ["timing", "memory", "floor"] },
1130
+ benchmark: strOrNull,
1131
+ size: strOrNull,
1132
+ message: str
1133
+ }
1134
+ },
1003
1135
  reading: {
1004
1136
  type: "object",
1005
1137
  required: ["processes", "cases", "importCost"],
1006
1138
  properties: {
1007
1139
  processes: int,
1008
1140
  cases: { type: "array", items: { $ref: "#/$defs/caseReading" } },
1141
+ // Added within version 1, so not required.
1142
+ memoryFloor: orNull({ $ref: "#/$defs/peak" }),
1143
+ failures: { type: "array", items: { $ref: "#/$defs/failure" } },
1009
1144
  importCost: orNull({
1010
1145
  type: "object",
1011
1146
  required: [
@@ -1105,23 +1240,7 @@ var RESULTS_SCHEMA = {
1105
1240
  }
1106
1241
  })
1107
1242
  ),
1108
- peak: orNull(
1109
- measuredOrSkipped({
1110
- type: "object",
1111
- required: ["status", "calls", "peakHeapBytes", "medianPeakHeapBytes"],
1112
- properties: {
1113
- status: { const: "measured" },
1114
- calls: int,
1115
- peakHeapBytes: num,
1116
- medianPeakHeapBytes: num,
1117
- // Added within version 1, so not required.
1118
- peakExternalBytes: num,
1119
- medianPeakExternalBytes: num,
1120
- peakTotalBytes: num,
1121
- medianPeakTotalBytes: num
1122
- }
1123
- })
1124
- ),
1243
+ peak: orNull({ $ref: "#/$defs/peak" }),
1125
1244
  longRun: orNull(
1126
1245
  measuredOrSkipped({
1127
1246
  type: "object",
@@ -1235,7 +1354,8 @@ var USAGE = `Usage:
1235
1354
  cosyte-bench check <results.json>
1236
1355
 
1237
1356
  Exit codes: 0 clean, 1 a finding (a regression, or results that do not match the schema),
1238
- 2 could not measure or could not read its input.
1357
+ 2 could not measure or could not read its input. A run that could not measure a case still
1358
+ writes every other case, lists what failed, and exits 2.
1239
1359
  `;
1240
1360
  var UsageError = class extends Error {
1241
1361
  };
@@ -1423,7 +1543,8 @@ async function main(argv, io) {
1423
1543
  subject: { target: rel(target), commit },
1424
1544
  reading
1425
1545
  },
1426
- 0
1546
+ // Whatever measured is written; a part that could not be is still exit 2.
1547
+ reading.failures.length > 0 ? 2 : 0
1427
1548
  );
1428
1549
  }
1429
1550
  {