@alejoamiras/aztec-benchmark 0.0.0-canary.gb8d1485

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.
@@ -0,0 +1,408 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ /**
5
+ * Formats system info as a markdown table.
6
+ * @param {object} systemInfo - The system info object from benchmark JSON.
7
+ * @returns {string} Markdown table showing system hardware info.
8
+ */
9
+ function formatSystemInfoTable(systemInfo) {
10
+ if (!systemInfo) {
11
+ return '| CPU | Cores | RAM | Arch |\n|-----|-------|-----|------|\n| N/A | N/A | N/A | N/A |\n';
12
+ }
13
+
14
+ const cpu = systemInfo.cpuModel || 'N/A';
15
+ const cores = systemInfo.cpuCores || 'N/A';
16
+ const ram = systemInfo.totalMemoryGiB ? `${systemInfo.totalMemoryGiB} GiB` : 'N/A';
17
+ const arch = systemInfo.arch || 'N/A';
18
+
19
+ return [
20
+ '| CPU | Cores | RAM | Arch |',
21
+ '|-----|-------|-----|------|',
22
+ `| ${cpu} | ${cores} | ${ram} | ${arch} |`,
23
+ '',
24
+ ].join('\n');
25
+ }
26
+
27
+ /**
28
+ * Extracts DA (Data Availability) gas from a benchmark result.
29
+ * @param {object} result - The benchmark result object.
30
+ * @returns {number} The DA gas value, or 0 if not found.
31
+ */
32
+ const getDaGas = (result) => result?.gas?.gasLimits?.daGas ?? 0;
33
+ /**
34
+ * Extracts L2 gas from a benchmark result.
35
+ * @param {object} result - The benchmark result object.
36
+ * @returns {number} The L2 gas value, or 0 if not found.
37
+ */
38
+ const getL2Gas = (result) => result?.gas?.gasLimits?.l2Gas ?? 0;
39
+ /**
40
+ * Extracts proving time from a benchmark result.
41
+ * @param {object} result - The benchmark result object.
42
+ * @returns {number} The proving time in milliseconds, or 0 if not found.
43
+ */
44
+ const getProvingTime = (result) => result?.provingTime ?? 0;
45
+
46
+ /**
47
+ * Formats the difference between two numbers as a string, including percentage change.
48
+ * Handles cases like zero main value (infinite increase) or zero pr value (100% decrease).
49
+ * @param {number} main - The base value.
50
+ * @param {number} pr - The new value (from Pull Request).
51
+ * @returns {string} A formatted string representing the difference, or an empty string if no significant change.
52
+ */
53
+ const formatDiff = (main, pr) => {
54
+ if (main === 0 && pr === 0) return ''; // Use empty string for no change if both zero
55
+ if (main === 0) return '+Inf%'; // Handle infinite increase
56
+ if (pr === 0) return '-100%'; // Handle 100% decrease
57
+
58
+ const diff = pr - main;
59
+ if (diff === 0) return ''; // Use empty string for no change
60
+
61
+ const pct = (diff / main) * 100;
62
+ const sign = diff > 0 ? '+' : '';
63
+
64
+ if (Math.abs(pct) < 0.01 && Math.abs(diff) < 1) return ''; // Threshold for small changes
65
+ // Format with commas and show percentage
66
+ return `${sign}${diff.toLocaleString()} (${sign}${pct.toFixed(1)}%)`;
67
+ };
68
+
69
+ /**
70
+ * Determines an emoji status based on benchmark metric changes and a threshold.
71
+ * @param {object} metrics - An object containing main and pr values for gates, daGas, and l2Gas.
72
+ * @param {number} threshold - The percentage threshold for significant change.
73
+ * @returns {string} An emoji: '🚮' for removed, '🆕' for new, '🔴' for regression, '🟢' for improvement, '⚪' for no significant change.
74
+ */
75
+ const getStatusEmoji = (metrics, threshold) => {
76
+ const isRemoved =
77
+ metrics.gates.pr === 0 &&
78
+ metrics.daGas.pr === 0 &&
79
+ metrics.l2Gas.pr === 0 &&
80
+ (metrics.gates.main > 0 || metrics.daGas.main > 0 || metrics.l2Gas.main > 0);
81
+ const isNew =
82
+ metrics.gates.main === 0 &&
83
+ metrics.daGas.main === 0 &&
84
+ metrics.l2Gas.main === 0 &&
85
+ (metrics.gates.pr > 0 || metrics.daGas.pr > 0 || metrics.l2Gas.pr > 0);
86
+
87
+ if (isRemoved) return '🚮';
88
+ if (isNew) return '🆕';
89
+
90
+ // Avoid division by zero, handle infinite increases
91
+ const gateDiffPct =
92
+ metrics.gates.main === 0
93
+ ? metrics.gates.pr > 0
94
+ ? Infinity
95
+ : 0
96
+ : (metrics.gates.pr - metrics.gates.main) / metrics.gates.main;
97
+ const daGasDiffPct =
98
+ metrics.daGas.main === 0
99
+ ? metrics.daGas.pr > 0
100
+ ? Infinity
101
+ : 0
102
+ : (metrics.daGas.pr - metrics.daGas.main) / metrics.daGas.main;
103
+ const l2GasDiffPct =
104
+ metrics.l2Gas.main === 0
105
+ ? metrics.l2Gas.pr > 0
106
+ ? Infinity
107
+ : 0
108
+ : (metrics.l2Gas.pr - metrics.l2Gas.main) / metrics.l2Gas.main;
109
+
110
+ const metricsDiffs = [gateDiffPct, daGasDiffPct, l2GasDiffPct].filter((m) => isFinite(m));
111
+ const hasInfiniteIncrease = [gateDiffPct, daGasDiffPct, l2GasDiffPct].some((m) => m === Infinity);
112
+
113
+ // Use threshold percentage directly
114
+ const thresholdDecimal = threshold / 100.0;
115
+
116
+ const hasRegression = hasInfiniteIncrease || metricsDiffs.some((m) => m > thresholdDecimal);
117
+ const hasImprovement = metricsDiffs.some((m) => m < -thresholdDecimal);
118
+
119
+ if (hasRegression) return '🔴'; // Regression
120
+ if (hasImprovement) return '🟢'; // Improvement
121
+ return '⚪'; // No significant change / within threshold
122
+ };
123
+
124
+ /**
125
+ * Finds pairs of benchmark report files (base and PR/latest) in a directory.
126
+ * Now includes new contracts that only have PR reports (no corresponding base report).
127
+ * @param {string} reportsDir - The directory containing benchmark reports.
128
+ * @param {string} baseSuffix - The suffix for base report filenames (e.g., '_base').
129
+ * @param {string} prSuffix - The suffix for PR/latest report filenames (e.g., '_latest').
130
+ * @returns {Array<object>} An array of pairs, each with contractName, baseJsonPath (or null), and prJsonPath.
131
+ */
132
+ function findBenchmarkPairs(reportsDir, baseSuffix, prSuffix) {
133
+ const pairs = [];
134
+ const prSuffixPattern = `${prSuffix}.benchmark.json`;
135
+ const baseSuffixPattern = `${baseSuffix}.benchmark.json`;
136
+
137
+ try {
138
+ const files = fs.readdirSync(reportsDir);
139
+ for (const file of files) {
140
+ if (file.endsWith(prSuffixPattern)) {
141
+ // Extract contract name from PR filename
142
+ const contractName = file.substring(0, file.length - prSuffixPattern.length);
143
+ // Construct expected baseline filename
144
+ const baseFilename = `${contractName}${baseSuffixPattern}`;
145
+ const baseJsonPath = path.join(reportsDir, baseFilename);
146
+ const prJsonPath = path.join(reportsDir, file);
147
+
148
+ // Include all PR reports, whether or not they have a corresponding base report
149
+ pairs.push({
150
+ contractName,
151
+ baseJsonPath: fs.existsSync(baseJsonPath) ? baseJsonPath : null,
152
+ prJsonPath,
153
+ });
154
+ }
155
+ }
156
+ } catch (error) {
157
+ // Handle cases where the directory doesn't exist
158
+ if (error.code === 'ENOENT') {
159
+ console.warn(`Reports directory not found: ${reportsDir}`);
160
+ } else {
161
+ console.error(`Error reading reports directory ${reportsDir}:`, error);
162
+ }
163
+ }
164
+ return pairs;
165
+ }
166
+
167
+ /**
168
+ * Generates an expandable circuit breakdown section for all functions in a contract.
169
+ * Uses <details>/<summary> HTML for a collapsible view placed below the summary table.
170
+ * @param {object} comparison - The comparison object keyed by function name.
171
+ * @param {string[]} sortedNames - Sorted function names.
172
+ * @returns {string} HTML string with the expandable circuit breakdown, or empty string if no data.
173
+ */
174
+ function generateCircuitBreakdownSection(comparison, sortedNames, contractName) {
175
+ const hasAnyCircuitData = sortedNames.some((name) => {
176
+ const gc = comparison[name]?.gateCounts;
177
+ return gc && gc.pr.length > 0;
178
+ });
179
+ if (!hasAnyCircuitData) return '';
180
+
181
+ const lines = ['<details>', `<summary>🔎 ${contractName} circuit details</summary>`, ''];
182
+
183
+ for (const funcName of sortedNames) {
184
+ const gc = comparison[funcName]?.gateCounts;
185
+ if (!gc || gc.pr.length === 0) continue;
186
+
187
+ lines.push(`#### \`${funcName}\``, '', '| Circuit | Gates |', '|---------|---:|');
188
+
189
+ for (const entry of gc.pr) {
190
+ lines.push(`| \`${entry.circuitName}\` | ${entry.gateCount.toLocaleString()} |`);
191
+ }
192
+
193
+ lines.push('');
194
+ }
195
+
196
+ lines.push('</details>');
197
+ return lines.join('\n');
198
+ }
199
+
200
+ /**
201
+ * Generates an HTML table comparing benchmark results for a single contract.
202
+ * Handles new contracts where baseJsonPath may be null (no base report exists).
203
+ * @param {object} pair - An object containing contractName, baseJsonPath (or null), and prJsonPath.
204
+ * @param {number} threshold - The percentage threshold for highlighting regressions.
205
+ * @returns {string} An HTML string representing the comparison table, or an error message.
206
+ */
207
+ function generateContractComparisonTable(pair, threshold, { circuitDetails = false } = {}) {
208
+ const { contractName, baseJsonPath, prJsonPath } = pair;
209
+ const isNewContract = baseJsonPath === null;
210
+
211
+ if (isNewContract) {
212
+ console.log(` Generating report for new contract: ${contractName} in ${prJsonPath}`);
213
+ } else {
214
+ console.log(` Comparing: ${baseJsonPath} vs ${prJsonPath}`);
215
+ }
216
+
217
+ // Check that PR report exists
218
+ if (!fs.existsSync(prJsonPath)) {
219
+ return `*Error: PR report file missing for ${contractName}: ${prJsonPath}*`;
220
+ }
221
+
222
+ // Check that base report exists (if not a new contract)
223
+ if (!isNewContract && !fs.existsSync(baseJsonPath)) {
224
+ return `*Error: Base report file missing for ${contractName}: ${baseJsonPath}*`;
225
+ }
226
+
227
+ let mainData, prData;
228
+ try {
229
+ // For new contracts, use empty data structure for base
230
+ if (isNewContract) {
231
+ mainData = { results: [] };
232
+ } else {
233
+ mainData = JSON.parse(fs.readFileSync(baseJsonPath, 'utf-8'));
234
+ }
235
+ prData = JSON.parse(fs.readFileSync(prJsonPath, 'utf-8'));
236
+ } catch (e) {
237
+ return `*Error parsing benchmark JSON for ${contractName}: ${e.message}*`;
238
+ }
239
+
240
+ if (!mainData || !mainData.results || !prData || !prData.results) {
241
+ return `*Skipping ${contractName}: Invalid JSON structure (missing results array).*`;
242
+ }
243
+
244
+ const comparison = {};
245
+ const allFunctionNames = new Set([...mainData.results.map((r) => r.name), ...prData.results.map((r) => r.name)]);
246
+
247
+ for (const name of allFunctionNames) {
248
+ if (
249
+ !name ||
250
+ name.startsWith('unknown_function') ||
251
+ name.includes('(FAILED)') ||
252
+ name === 'BENCHMARK_RUNNER_ERROR'
253
+ ) {
254
+ console.log(` Skipping comparison for malformed/failed entry: ${name}`);
255
+ continue;
256
+ }
257
+ const mainResult = mainData.results.find((r) => r.name === name);
258
+ const prResult = prData.results.find((r) => r.name === name);
259
+
260
+ comparison[name] = {
261
+ gates: { main: mainResult?.totalGateCount ?? 0, pr: prResult?.totalGateCount ?? 0 },
262
+ daGas: { main: getDaGas(mainResult), pr: getDaGas(prResult) },
263
+ l2Gas: { main: getL2Gas(mainResult), pr: getL2Gas(prResult) },
264
+ provingTime: { main: getProvingTime(mainResult), pr: getProvingTime(prResult) },
265
+ gateCounts: { main: mainResult?.gateCounts ?? [], pr: prResult?.gateCounts ?? [] },
266
+ };
267
+ }
268
+
269
+ const output = [
270
+ '<table>',
271
+ '<thead>',
272
+ '<tr>',
273
+ '<th>🚦</th>',
274
+ '<th>Function</th>',
275
+ '<th colspan="3">Gates</th>',
276
+ '<th colspan="3">DA Gas</th>',
277
+ '<th colspan="3">L2 Gas</th>',
278
+ '<th colspan="3">Proving Time (ms)</th>',
279
+ '</tr>',
280
+ '<tr>',
281
+ '<th></th>',
282
+ '<th></th>',
283
+ '<th>Base</th>',
284
+ '<th>PR</th>',
285
+ '<th>Diff</th>',
286
+ '<th>Base</th>',
287
+ '<th>PR</th>',
288
+ '<th>Diff</th>',
289
+ '<th>Base</th>',
290
+ '<th>PR</th>',
291
+ '<th>Diff</th>',
292
+ '<th>Base</th>',
293
+ '<th>PR</th>',
294
+ '<th>Diff</th>',
295
+ '</tr>',
296
+ '</thead>',
297
+ '<tbody>',
298
+ ];
299
+
300
+ const sortedNames = Object.keys(comparison).sort();
301
+
302
+ if (sortedNames.length === 0) {
303
+ return '*No comparable functions found between reports.*';
304
+ }
305
+
306
+ for (const funcName of sortedNames) {
307
+ const metrics = comparison[funcName];
308
+ if (!metrics) continue;
309
+
310
+ const statusEmoji = getStatusEmoji(metrics, threshold);
311
+ const ptMain = metrics.provingTime.main > 0 ? Math.round(metrics.provingTime.main).toLocaleString() : 'N/A';
312
+ const ptPr = metrics.provingTime.pr > 0 ? Math.round(metrics.provingTime.pr).toLocaleString() : 'N/A';
313
+ const ptDiff = formatDiff(Math.round(metrics.provingTime.main), Math.round(metrics.provingTime.pr));
314
+ output.push(
315
+ '<tr>',
316
+ `<td align="center">${statusEmoji}</td>`,
317
+ `<td><code>${funcName}</code></td>`,
318
+ // Gates
319
+ `<td align="right">${metrics.gates.main.toLocaleString()}</td>`,
320
+ `<td align="right">${metrics.gates.pr.toLocaleString()}</td>`,
321
+ `<td align="right">${formatDiff(metrics.gates.main, metrics.gates.pr)}</td>`,
322
+ // DA Gas
323
+ `<td align="right">${metrics.daGas.main.toLocaleString()}</td>`,
324
+ `<td align="right">${metrics.daGas.pr.toLocaleString()}</td>`,
325
+ `<td align="right">${formatDiff(metrics.daGas.main, metrics.daGas.pr)}</td>`,
326
+ // L2 Gas
327
+ `<td align="right">${metrics.l2Gas.main.toLocaleString()}</td>`,
328
+ `<td align="right">${metrics.l2Gas.pr.toLocaleString()}</td>`,
329
+ `<td align="right">${formatDiff(metrics.l2Gas.main, metrics.l2Gas.pr)}</td>`,
330
+ // Proving Time
331
+ `<td align="right">${ptMain}</td>`,
332
+ `<td align="right">${ptPr}</td>`,
333
+ `<td align="right">${ptDiff}</td>`,
334
+ '</tr>',
335
+ );
336
+ }
337
+
338
+ output.push('</tbody>', '</table>');
339
+
340
+ // Add expandable circuit breakdown section below the summary table
341
+ if (circuitDetails) {
342
+ const circuitSection = generateCircuitBreakdownSection(comparison, sortedNames, contractName);
343
+ if (circuitSection) {
344
+ output.push('', circuitSection);
345
+ }
346
+ }
347
+
348
+ return output.join('\n');
349
+ }
350
+
351
+ /**
352
+ * Main function to run the benchmark comparison.
353
+ * It finds benchmark report pairs, generates comparison tables for each, and combines them into a single markdown output.
354
+ * @param {object} inputs - The input parameters for the comparison.
355
+ * @param {string} inputs.reportsDir - Directory where benchmark reports are stored.
356
+ * @param {string} inputs.baseSuffix - Suffix for baseline report files.
357
+ * @param {string} inputs.prSuffix - Suffix for PR/current report files.
358
+ * @param {number} inputs.threshold - Percentage threshold for regressions.
359
+ * @returns {string} A markdown string containing the full comparison report.
360
+ */
361
+ function runComparison(inputs) {
362
+ const { reportsDir, baseSuffix, prSuffix, threshold, circuitDetails = false } = inputs;
363
+ console.log('Comparison script starting...');
364
+ console.log(` Reports Dir: ${reportsDir} (expected ./benchmarks)`);
365
+ console.log(` Base Suffix: '${baseSuffix}' (expected _base)`);
366
+ console.log(` PR Suffix: '${prSuffix}' (expected _latest)`);
367
+ console.log(` Threshold: ${threshold}%`);
368
+
369
+ // Find pairs by scanning the directory
370
+ const benchmarkPairs = findBenchmarkPairs(reportsDir, baseSuffix, prSuffix);
371
+
372
+ if (!benchmarkPairs.length) {
373
+ console.log('No matching benchmark report pairs found in the directory.');
374
+ return '# Benchmark Comparison\n\nNo matching benchmark report pairs found to compare.\n';
375
+ }
376
+
377
+ const markdownOutput = ['<!-- benchmark-diff -->\n', '# Benchmark Comparison\n'];
378
+
379
+ // Sort pairs by contract name for consistent report order
380
+ benchmarkPairs.sort((a, b) => a.contractName.localeCompare(b.contractName));
381
+
382
+ // Read system info from benchmark report (all reports have the same info since they run on the same machine)
383
+ let systemInfo = null;
384
+ if (benchmarkPairs.length > 0) {
385
+ try {
386
+ const firstReport = JSON.parse(fs.readFileSync(benchmarkPairs[0].prJsonPath, 'utf-8'));
387
+ systemInfo = firstReport.systemInfo;
388
+ } catch (e) {
389
+ console.warn('Could not read system info from benchmark report:', e.message);
390
+ }
391
+ }
392
+
393
+ // Add system info table (displayed once at the top)
394
+ markdownOutput.push(formatSystemInfoTable(systemInfo));
395
+
396
+ for (const pair of benchmarkPairs) {
397
+ console.log(`\nProcessing contract: ${pair.contractName}...`);
398
+ const tableMarkdown = generateContractComparisonTable(pair, threshold, { circuitDetails });
399
+ markdownOutput.push(`## Contract: ${pair.contractName}\n`);
400
+ markdownOutput.push(tableMarkdown);
401
+ markdownOutput.push('\n');
402
+ }
403
+
404
+ console.log(`\nComparison report generated for ${benchmarkPairs.length} contract pair(s).`);
405
+ return markdownOutput.join('\n');
406
+ }
407
+
408
+ module.exports = { runComparison };