@cosyte/bench 0.1.1 → 0.1.3

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,
@@ -67,6 +69,10 @@ var BENCH_CONTRACT = Object.freeze({
67
69
  GC_SETTLE_ROUNDS: 2,
68
70
  GC_MAX_ROUNDS: 7,
69
71
  IMPORT_RUNS: 20,
72
+ LARGE_PASS_MS: 50,
73
+ LARGE_WARMUP_WINDOW: 5,
74
+ LARGE_WARMUP_MAX_MS: 3e4,
75
+ LONG_RUN_MAX_MS: 6e4,
70
76
  REQUIRED_FALSE_ALARM_RATE: 0.01,
71
77
  REQUIRED_CONFIDENCE: 0.95
72
78
  });
@@ -110,11 +116,13 @@ function runNode(entry, args, execArgv, timeoutMs, execPath = process.execPath)
110
116
  }
111
117
  function flagsFor(job) {
112
118
  if (job.mode === "timing") return [];
113
- if (job.mode === "heap") return ["--expose-gc"];
119
+ const memory = ["--expose-gc", "--no-concurrent-array-buffer-sweeping"];
120
+ if (job.mode === "heap" || job.mode === "floor") return memory;
114
121
  const mib = String(BENCH_CONTRACT.ALLOC_SEMI_SPACE_MIB);
115
- return ["--expose-gc", `--min-semi-space-size=${mib}`, `--max-semi-space-size=${mib}`];
122
+ return [...memory, `--min-semi-space-size=${mib}`, `--max-semi-space-size=${mib}`];
116
123
  }
117
124
  function describe(job) {
125
+ if (job.mode === "floor") return "floor job";
118
126
  return `${job.mode} job for "${job.case.benchmark} @ ${job.case.size}"`;
119
127
  }
120
128
  async function runInChild(job, options = {}) {
@@ -380,11 +388,18 @@ async function compareBuilds(options) {
380
388
  s.skip = `${side}: ${result.reason}`;
381
389
  break;
382
390
  }
383
- got.set(side, result.samplesMsPerOp);
391
+ got.set(side, { samples: result.samplesMsPerOp, plan: result.warmup.plan });
384
392
  }
385
393
  const base = got.get("base");
386
394
  const head = got.get("head");
387
- if (base !== void 0 && head !== void 0) s.rounds.push({ base, head });
395
+ if (base !== void 0 && head !== void 0) {
396
+ s.rounds.push({
397
+ base: base.samples,
398
+ head: head.samples,
399
+ basePlan: base.plan,
400
+ headPlan: head.plan
401
+ });
402
+ }
388
403
  }
389
404
  options.onProgress?.(`round ${String(r + 1)} of ${String(BENCH_CONTRACT.ROUNDS)} done`);
390
405
  }
@@ -398,7 +413,9 @@ async function compareBuilds(options) {
398
413
  baseMsPerOp: rounds.map((p) => Math.min(...p.base)),
399
414
  headMsPerOp: rounds.map((p) => Math.min(...p.head)),
400
415
  baseSamples: rounds.map((p) => p.base),
401
- headSamples: rounds.map((p) => p.head)
416
+ headSamples: rounds.map((p) => p.head),
417
+ basePlans: rounds.map((p) => p.basePlan),
418
+ headPlans: rounds.map((p) => p.headPlan)
402
419
  };
403
420
  if (skip !== null) {
404
421
  return {
@@ -540,18 +557,55 @@ async function calibrateComparison(options) {
540
557
  }
541
558
  return summarizeCalibration(options.inject ?? 0, runs);
542
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
+ }
543
579
 
544
580
  // src/reading.ts
581
+ var WrongKind = class extends Error {
582
+ };
545
583
  function expectTiming(result) {
546
584
  if (result.mode !== "timing")
547
- throw new Error("@cosyte/bench: a timing job returned another kind");
585
+ throw new WrongKind("@cosyte/bench: a timing job returned another kind");
548
586
  return result;
549
587
  }
550
588
  function expectMemory(result) {
551
- if (result.mode === "timing")
552
- 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");
553
591
  return result;
554
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
+ }
555
609
  function summarizeThroughput(first, rest) {
556
610
  const measured = [];
557
611
  for (const t of [first, ...rest]) {
@@ -560,7 +614,8 @@ function summarizeThroughput(first, rest) {
560
614
  }
561
615
  const per = measured.map((t) => ({
562
616
  samplesMsPerOp: t.samplesMsPerOp,
563
- medianMsPerOp: t.medianMsPerOp
617
+ medianMsPerOp: t.medianMsPerOp,
618
+ plan: t.warmup.plan
564
619
  }));
565
620
  const mid = median(per.map((x) => x.medianMsPerOp));
566
621
  const perOpBytes = first.bytesPerPass === null ? null : first.bytesPerPass / first.inputsPerPass;
@@ -572,48 +627,102 @@ function summarizeThroughput(first, rest) {
572
627
  bytesPerSecond: perOpBytes === null ? null : round(perOpBytes * (1e3 / mid), 0)
573
628
  };
574
629
  }
630
+ function longRunFits(throughput, inputsPerPass) {
631
+ if (throughput.status !== "measured") return false;
632
+ const roundMs = Math.max(
633
+ BENCH_CONTRACT.LONG_RUN_ROUND_MS,
634
+ throughput.medianMsPerOp * inputsPerPass
635
+ );
636
+ return BENCH_CONTRACT.LONG_RUN_ROUNDS * roundMs <= BENCH_CONTRACT.LONG_RUN_MAX_MS;
637
+ }
575
638
  async function readBuild(options) {
576
639
  const runJob = options.runJob;
577
640
  const processes = options.processes ?? 3;
578
641
  if (!Number.isInteger(processes) || processes < 1) {
579
642
  throw new TypeError("@cosyte/bench: processes must be a positive integer");
580
643
  }
644
+ const memory = options.memory ?? true;
581
645
  const base = { suite: options.suiteFile, target: options.target };
646
+ const listed = listCases(options.suite, options.only);
582
647
  const cases = [];
583
- for (const c of listCases(options.suite, options.only)) {
584
- const timing = async (latency2) => expectTiming(await runJob({ mode: "timing", ...base, case: c, latency: latency2, inject: 0 }));
585
- const first = await timing(true);
586
- const rest = [];
587
- for (let p = 1; p < processes; p++) rest.push(await timing(false));
588
- const throughput = summarizeThroughput(first, rest);
589
- const latency = first.status === "measured" ? first.latency : null;
590
- let allocations = null;
591
- let peak = null;
592
- let longRun = null;
593
- if (options.memory ?? true) {
594
- allocations = expectMemory(await runJob({ mode: "alloc", ...base, case: c })).allocations;
595
- const { benchmark } = findCase(options.suite, c);
596
- const longRunSize = benchmark.longRun ?? benchmark.sizes[0].label;
597
- const heap = expectMemory(
598
- await runJob({ mode: "heap", ...base, case: c, longRun: c.size === longRunSize })
599
- );
600
- peak = heap.peak;
601
- longRun = heap.longRun;
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
+ };
658
+ for (const name of new Set(listed.map((c) => c.benchmark))) {
659
+ const timed = [];
660
+ for (const c of listed.filter((l) => l.benchmark === name)) {
661
+ const timing = async (latency) => expectTiming(await runJob({ mode: "timing", ...base, case: c, latency, inject: 0 }));
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
+ }
670
+ }
671
+ const largest = timed.filter((t) => longRunFits(t.throughput, t.first.inputsPerPass)).pop();
672
+ for (const { c, first, throughput } of timed) {
673
+ let allocations = null;
674
+ let peak = null;
675
+ let longRun = null;
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
+ }
694
+ }
695
+ cases.push({
696
+ ...c,
697
+ inputsPerPass: first.inputsPerPass,
698
+ bytesPerPass: first.bytesPerPass,
699
+ throughput,
700
+ latency: first.status === "measured" ? first.latency : null,
701
+ allocations,
702
+ peak,
703
+ longRun
704
+ });
705
+ options.onProgress?.(`read ${c.benchmark} @ ${c.size}`);
706
+ }
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);
602
714
  }
603
- cases.push({
604
- ...c,
605
- inputsPerPass: first.inputsPerPass,
606
- bytesPerPass: first.bytesPerPass,
607
- throughput,
608
- latency,
609
- allocations,
610
- peak,
611
- longRun
612
- });
613
- options.onProgress?.(`read ${c.benchmark} @ ${c.size}`);
614
715
  }
615
716
  const importCost = options.importCost ?? true ? await options.measureImport(options.target) : null;
616
- 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
+ };
617
726
  }
618
727
 
619
728
  // src/report.ts
@@ -698,26 +807,54 @@ function renderMarkdown(doc) {
698
807
  const r = doc.reading;
699
808
  lines.push(...header(doc, "Reading"));
700
809
  lines.push(
701
- `Throughput is the median of ${String(r.processes)} fresh processes. Latency is per call, nearest rank. Memory: allocations per operation, the peak heap of one call above the settled heap, and whether the settled heap stays flat over a long run.`,
702
- "",
703
- "| Benchmark | Size | Per op | Ops/s | Bytes/s | p50 | p95 | p99 | Alloc/op | Peak heap | Long run |",
704
- "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
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
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
705
823
  );
706
824
  for (const c of r.cases) {
707
825
  const t = c.throughput;
708
- const perOp = t.status === "measured" ? time(t.medianMsPerOp) : `skipped (${t.reason})`;
826
+ const large = t.status === "measured" && t.processes.some((p2) => p2.plan === "large") ? " (large-case plan)" : "";
827
+ const perOp = t.status === "measured" ? `${time(t.medianMsPerOp)}${large}` : `skipped (${t.reason})`;
709
828
  const ops = t.status === "measured" ? String(t.opsPerSecond) : "n/a";
710
829
  const bps = t.status === "measured" && t.bytesPerSecond !== null ? `${bytes(t.bytesPerSecond)}/s` : "n/a";
711
830
  const l = c.latency;
712
831
  const lat = l === null ? ["n/a", "n/a", "n/a"] : l.status === "measured" ? [ns(l.p50Ns), ns(l.p95Ns), ns(l.p99Ns)] : [`skipped (${l.reason})`, "n/a", "n/a"];
713
832
  const a = c.allocations;
714
- const alloc = a === null ? "n/a" : a.status === "measured" ? bytes(a.heapBytesPerOp + a.externalBytesPerOp) : `skipped (${a.reason})`;
833
+ const alloc = a === null ? "n/a" : a.status === "measured" ? `${bytes(a.heapBytesPerOp + a.externalBytesPerOp)}${a.method === "gc-accounted" ? " (collections counted)" : ""}` : `skipped (${a.reason})`;
715
834
  const p = c.peak;
716
- const peak = p === null ? "n/a" : p.status === "measured" ? bytes(p.peakHeapBytes) : `skipped (${p.reason})`;
835
+ const optional = (n) => n === void 0 ? "n/a" : bytes(n);
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})`);
717
843
  const lr = c.longRun;
718
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})`;
719
845
  lines.push(
720
- `| ${c.benchmark} | ${c.size} | ${perOp} | ${ops} | ${bps} | ${lat.join(" | ")} | ${alloc} | ${peak} | ${long} |`
846
+ `| ${c.benchmark} | ${c.size} | ${perOp} | ${ops} | ${bps} | ${lat.join(" | ")} | ${alloc} | ${peak.join(" | ")} | ${long} |`
847
+ );
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
+ )
721
858
  );
722
859
  }
723
860
  if (r.importCost !== null) {
@@ -783,6 +920,7 @@ var skipped = {
783
920
  properties: { status: { const: "skipped" }, reason: str }
784
921
  };
785
922
  var measuredOrSkipped = (measured) => ({ oneOf: [measured, skipped] });
923
+ var plan = { enum: ["standard", "large"] };
786
924
  var orNull = (s) => ({ oneOf: [{ type: "null" }, s] });
787
925
  var RESULTS_SCHEMA = {
788
926
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -899,6 +1037,9 @@ var RESULTS_SCHEMA = {
899
1037
  headMsPerOp: nums,
900
1038
  ratios: nums,
901
1039
  medianRatio: numOrNull,
1040
+ // Added within version 1, so not required: a document written before them is still valid.
1041
+ basePlans: { type: "array", items: plan },
1042
+ headPlans: { type: "array", items: plan },
902
1043
  interval: { $ref: "#/$defs/interval" },
903
1044
  verdict: {
904
1045
  enum: ["regression", "improvement", "no-regression", "inconclusive", "skipped"]
@@ -960,12 +1101,46 @@ var RESULTS_SCHEMA = {
960
1101
  }
961
1102
  }
962
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
+ },
963
1135
  reading: {
964
1136
  type: "object",
965
1137
  required: ["processes", "cases", "importCost"],
966
1138
  properties: {
967
1139
  processes: int,
968
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" } },
969
1144
  importCost: orNull({
970
1145
  type: "object",
971
1146
  required: [
@@ -1028,7 +1203,8 @@ var RESULTS_SCHEMA = {
1028
1203
  items: {
1029
1204
  type: "object",
1030
1205
  required: ["samplesMsPerOp", "medianMsPerOp"],
1031
- properties: { samplesMsPerOp: nums, medianMsPerOp: num }
1206
+ // `plan` was added within version 1, so it is not required.
1207
+ properties: { samplesMsPerOp: nums, medianMsPerOp: num, plan }
1032
1208
  }
1033
1209
  },
1034
1210
  medianMsPerOp: num,
@@ -1055,24 +1231,16 @@ var RESULTS_SCHEMA = {
1055
1231
  required: ["status", "windowOps", "heapBytesPerOp", "externalBytesPerOp"],
1056
1232
  properties: {
1057
1233
  status: { const: "measured" },
1234
+ // Added within version 1, so not required.
1235
+ method: { enum: ["no-gc-window", "gc-accounted"] },
1236
+ collections: int,
1058
1237
  windowOps: int,
1059
1238
  heapBytesPerOp: num,
1060
1239
  externalBytesPerOp: num
1061
1240
  }
1062
1241
  })
1063
1242
  ),
1064
- peak: orNull(
1065
- measuredOrSkipped({
1066
- type: "object",
1067
- required: ["status", "calls", "peakHeapBytes", "medianPeakHeapBytes"],
1068
- properties: {
1069
- status: { const: "measured" },
1070
- calls: int,
1071
- peakHeapBytes: num,
1072
- medianPeakHeapBytes: num
1073
- }
1074
- })
1075
- ),
1243
+ peak: orNull({ $ref: "#/$defs/peak" }),
1076
1244
  longRun: orNull(
1077
1245
  measuredOrSkipped({
1078
1246
  type: "object",
@@ -1186,7 +1354,8 @@ var USAGE = `Usage:
1186
1354
  cosyte-bench check <results.json>
1187
1355
 
1188
1356
  Exit codes: 0 clean, 1 a finding (a regression, or results that do not match the schema),
1189
- 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.
1190
1359
  `;
1191
1360
  var UsageError = class extends Error {
1192
1361
  };
@@ -1374,7 +1543,8 @@ async function main(argv, io) {
1374
1543
  subject: { target: rel(target), commit },
1375
1544
  reading
1376
1545
  },
1377
- 0
1546
+ // Whatever measured is written; a part that could not be is still exit 2.
1547
+ reading.failures.length > 0 ? 2 : 0
1378
1548
  );
1379
1549
  }
1380
1550
  {