| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 9e8e908 commit 74234ee
3 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -25,9 +25,10 @@ function getTime(diff) { | |||
| 25 | 25 | // A run is an item in the job queue: { binary, filename, iter } | |
| 26 | 26 | // A config is an item in the subqueue: { binary, filename, iter, configs } | |
| 27 | 27 | class BenchmarkProgress { | |
| 28 | - constructor(queue, benchmarks) { | ||
| 28 | + constructor(queue, benchmarks, options = {}) { | ||
| 29 | 29 | this.queue = queue; // Scheduled runs. | |
| 30 | 30 | this.benchmarks = benchmarks; // Filenames of scheduled benchmarks. | |
| 31 | + this.analyze = !!options.analyze; // stdout is not piped, but unused. | ||
| 31 | 32 | this.completedRuns = 0; // Number of completed runs. | |
| 32 | 33 | this.scheduledRuns = queue.length; // Number of scheduled runs. | |
| 33 | 34 | // Time when starting to run benchmarks. | |
@@ -107,7 +108,10 @@ class BenchmarkProgress { | |||
| 107 | 108 | } | |
| 108 | 109 | ||
| 109 | 110 | updateProgress() { | |
| 110 | - if (!process.stderr.isTTY || process.stdout.isTTY) { | ||
| 111 | + // Progress renders on stderr when stdout is piped (not a TTY). | ||
| 112 | + // In --analyze mode, stdout is the terminal but is unused during | ||
| 113 | + // the run, so treat it the same as piped. | ||
| 114 | + if (!process.stderr.isTTY || (process.stdout.isTTY && !this.analyze)) { | ||
| 111 | 115 | return; | |
| 112 | 116 | } | |
| 113 | 117 | readline.clearLine(process.stderr); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ... | |||
| 13 | 13 | Run each benchmark in the <category> directory many times using two different | |
| 14 | 14 | node versions. More than one <category> directory can be specified. | |
| 15 | 15 | The output is formatted as csv, which can be processed using for | |
| 16 | - example 'compare.R'. | ||
| 16 | + example 'compare.R'. Use --analyze to perform statistical analysis | ||
| 17 | + directly without R. | ||
| 17 | 18 | ||
| 18 | 19 | --new ./new-node-binary new node binary (required) | |
| 19 | 20 | --old ./old-node-binary old node binary (required) | |
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ... | |||
| 24 | 25 | repeated) | |
| 25 | 26 | --set variable=value set benchmark variable (can be repeated) | |
| 26 | 27 | --no-progress don't show benchmark progress indicator | |
| 28 | + --analyze perform statistical analysis after benchmarks | ||
| 29 | + complete (Welch's t-test, effect size) instead | ||
| 30 | + of printing csv output | ||
| 31 | + --scale 1000 rate-to-integer multiplier for histogram | ||
| 32 | + precision when using --analyze (default: 1000) | ||
| 33 | + --max-regression N exit with code 1 if any statistically | ||
| 34 | + significant regression exceeds N% (implies | ||
| 35 | + --analyze) | ||
| 27 | 36 | ||
| 28 | 37 | Examples: | |
| 29 | 38 | --set CPUSET=0 Runs benchmarks on CPU core 0. | |
| 30 | 39 | --set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2. | |
| 31 | 40 | ||
| 32 | 41 | Note: The CPUSET format should match the specifications of the 'taskset' command | |
| 33 | - `, { arrayArgs: ['set', 'filter', 'exclude'], boolArgs: ['no-progress'] }); | ||
| 42 | + `, { arrayArgs: ['set', 'filter', 'exclude'], boolArgs: ['no-progress', 'analyze'] }); | ||
| 34 | 43 | ||
| 35 | 44 | if (!cli.optional.new || !cli.optional.old) { | |
| 36 | 45 | cli.abort(cli.usage); | |
| 37 | 46 | } | |
| 38 | 47 | ||
| 39 | 48 | const binaries = ['old', 'new']; | |
| 40 | 49 | const runs = cli.optional.runs ? parseInt(cli.optional.runs, 10) : 30; | |
| 50 | + const maxRegression = cli.optional['max-regression'] ? | ||
| 51 | + parseFloat(cli.optional['max-regression']) : | ||
| 52 | + 0; | ||
| 53 | + const analyze = !!cli.optional.analyze || maxRegression > 0; | ||
| 54 | + const scale = cli.optional.scale ? parseInt(cli.optional.scale, 10) : 1000; | ||
| 41 | 55 | const benchmarks = cli.benchmarks(); | |
| 42 | 56 | ||
| 43 | 57 | if (benchmarks.length === 0) { | |
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) { | |||
| 46 | 60 | return; | |
| 47 | 61 | } | |
| 48 | 62 | ||
| 63 | + // When --analyze is set, collect results for statistical analysis. | ||
| 64 | + const results = analyze ? new Map() : null; | ||
| 65 | + | ||
| 49 | 66 | // Create queue from the benchmarks list such both node versions are tested | |
| 50 | 67 | // `runs` amount of times each. | |
| 51 | 68 | // Note: BenchmarkProgress relies on this order to estimate | |
@@ -61,15 +78,17 @@ for (const filename of benchmarks) { | |||
| 61 | 78 | } | |
| 62 | 79 | // queue.length = binary.length * runs * benchmarks.length | |
| 63 | 80 | ||
| 64 | - // Print csv header | ||
| 65 | - console.log('"binary","filename","configuration","rate","time"'); | ||
| 81 | + // Print csv header (unless analyzing inline). | ||
| 82 | + if (!analyze) { | ||
| 83 | + console.log('"binary","filename","configuration","rate","time"'); | ||
| 84 | + } | ||
| 66 | 85 | ||
| 67 | 86 | const kStartOfQueue = 0; | |
| 68 | 87 | ||
| 69 | 88 | const showProgress = !cli.optional['no-progress']; | |
| 70 | 89 | let progress; | |
| 71 | 90 | if (showProgress) { | |
| 72 | - progress = new BenchmarkProgress(queue, benchmarks); | ||
| 91 | + progress = new BenchmarkProgress(queue, benchmarks, { analyze }); | ||
| 73 | 92 | progress.startQueue(kStartOfQueue); | |
| 74 | 93 | } | |
| 75 | 94 | ||
@@ -99,11 +118,20 @@ if (showProgress) { | |||
| 99 | 118 | conf += ` ${key}=${inspect(data.conf[key])}`; | |
| 100 | 119 | } | |
| 101 | 120 | conf = conf.slice(1); | |
| 102 | - // Escape quotes (") for correct csv formatting | ||
| 103 | - conf = conf.replace(/"/g, '""'); | ||
| 104 | 121 | ||
| 105 | - console.log(`"${job.binary}","${job.filename}","${conf}",` + | ||
| 106 | - `${data.rate},${data.time}`); | ||
| 122 | + if (analyze) { | ||
| 123 | + // Collect results for post-run analysis. | ||
| 124 | + const name = `${job.filename} ${conf}`; | ||
| 125 | + if (!results.has(name)) { | ||
| 126 | + results.set(name, { old: [], new: [] }); | ||
| 127 | + } | ||
| 128 | + results.get(name)[job.binary].push(data.rate); | ||
| 129 | + } else { | ||
| 130 | + // Escape quotes (") for correct csv formatting | ||
| 131 | + conf = conf.replace(/"/g, '""'); | ||
| 132 | + console.log(`"${job.binary}","${job.filename}","${conf}",` + | ||
| 133 | + `${data.rate},${data.time}`); | ||
| 134 | + } | ||
| 107 | 135 | if (showProgress) { | |
| 108 | 136 | // One item in the subqueue has been completed. | |
| 109 | 137 | progress.completeConfig(data); | |
@@ -125,6 +153,199 @@ if (showProgress) { | |||
| 125 | 153 | // If there are more benchmarks execute the next | |
| 126 | 154 | if (i + 1 < queue.length) { | |
| 127 | 155 | recursive(i + 1); | |
| 156 | + } else if (analyze) { | ||
| 157 | + printAnalysis(results, scale, maxRegression); | ||
| 128 | 158 | } | |
| 129 | 159 | }); | |
| 130 | 160 | })(kStartOfQueue); | |
| 161 | + | ||
| 162 | + function printAnalysis(results, scale, maxRegression) { | ||
| 163 | + const { createHistogram } = require('node:perf_hooks'); | ||
| 164 | + | ||
| 165 | + // Build per-benchmark histograms and run statistical tests. | ||
| 166 | + const rows = []; | ||
| 167 | + let maxNameLen = 0; | ||
| 168 | + | ||
| 169 | + let skipped = 0; | ||
| 170 | + | ||
| 171 | + for (const [name, { old: oldRates, new: newRates }] of results) { | ||
| 172 | + if (oldRates.length < 2 || newRates.length < 2) { | ||
| 173 | + skipped++; | ||
| 174 | + continue; | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + const hOld = createHistogram({ figures: 3 }); | ||
| 178 | + const hNew = createHistogram({ figures: 3 }); | ||
| 179 | + | ||
| 180 | + for (const r of oldRates) hOld.record(Math.max(1, Math.round(r * scale))); | ||
| 181 | + for (const r of newRates) hNew.record(Math.max(1, Math.round(r * scale))); | ||
| 182 | + | ||
| 183 | + const oldMean = oldRates.reduce((a, b) => a + b, 0) / oldRates.length; | ||
| 184 | + const newMean = newRates.reduce((a, b) => a + b, 0) / newRates.length; | ||
| 185 | + const improvement = ((newMean - oldMean) / oldMean) * 100; | ||
| 186 | + | ||
| 187 | + // Query the three confidence levels. The p-value and t-statistic | ||
| 188 | + // are the same regardless of the confidence level, so we extract | ||
| 189 | + // them from the first result. | ||
| 190 | + const w95 = hOld.welchTest(hNew, { confidence: 0.95 }); | ||
| 191 | + const w99 = hOld.welchTest(hNew, { confidence: 0.99 }); | ||
| 192 | + const w999 = hOld.welchTest(hNew, { confidence: 0.999 }); | ||
| 193 | + | ||
| 194 | + // Significance stars matching compare.R convention. | ||
| 195 | + let stars = ''; | ||
| 196 | + if (w95.pValue < 0.001) stars = '***'; | ||
| 197 | + else if (w95.pValue < 0.01) stars = ' **'; | ||
| 198 | + else if (w95.pValue < 0.05) stars = ' *'; | ||
| 199 | + | ||
| 200 | + // Confidence intervals expressed as percentage of the old mean. | ||
| 201 | + const ciPct = (w) => { | ||
| 202 | + const half = | ||
| 203 | + (w.confidenceInterval.upper - w.confidenceInterval.lower) / 2; | ||
| 204 | + return (half / (oldMean * scale)) * 100; | ||
| 205 | + }; | ||
| 206 | + | ||
| 207 | + rows.push({ | ||
| 208 | + name, | ||
| 209 | + stars, | ||
| 210 | + improvement, | ||
| 211 | + ci95: ciPct(w95), | ||
| 212 | + ci99: ciPct(w99), | ||
| 213 | + ci999: ciPct(w999), | ||
| 214 | + pValue: w95.pValue, | ||
| 215 | + }); | ||
| 216 | + | ||
| 217 | + if (name.length > maxNameLen) maxNameLen = name.length; | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + // Print header. | ||
| 221 | + const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length)); | ||
| 222 | + const rpad = (s, n) => ' '.repeat(Math.max(0, n - s.length)) + s; | ||
| 223 | + | ||
| 224 | + console.log(`${pad('', maxNameLen)} confidence` + | ||
| 225 | + ` improvement accuracy (*) (**) (***)`); | ||
| 226 | + | ||
| 227 | + for (const row of rows) { | ||
| 228 | + const imp = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)} %`; | ||
| 229 | + console.log( | ||
| 230 | + `${pad(row.name, maxNameLen)} ${pad(row.stars, 10)}` + | ||
| 231 | + ` ${rpad(imp, 11)}` + | ||
| 232 | + ` ±${row.ci95.toFixed(2)}%` + | ||
| 233 | + ` ±${row.ci99.toFixed(2)}%` + | ||
| 234 | + ` ±${row.ci999.toFixed(2)}%`, | ||
| 235 | + ); | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + if (skipped > 0) { | ||
| 239 | + console.log(''); | ||
| 240 | + console.log( | ||
| 241 | + `Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` + | ||
| 242 | + ` skipped because Welch's t-test requires at least 2 samples per` + | ||
| 243 | + ` binary. Use --runs 2 or higher.`, | ||
| 244 | + ); | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + // --- Bar chart visualization --- | ||
| 248 | + printChart(rows, maxNameLen); | ||
| 249 | + | ||
| 250 | + console.log(''); | ||
| 251 | + console.log( | ||
| 252 | + `Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` + | ||
| 253 | + `Use --scale to adjust precision if needed.\n`, | ||
| 254 | + ); | ||
| 255 | + console.log( | ||
| 256 | + `Be aware that when doing many comparisons the risk of a false-positive\n` + | ||
| 257 | + `result increases. In this case, there are ${rows.length} comparisons, ` + | ||
| 258 | + `you can thus\nexpect the following amount of false-positive results:\n` + | ||
| 259 | + ` ${(rows.length * 0.05).toFixed(2)} false positives, when considering ` + | ||
| 260 | + `a 5% risk acceptance (*, **, ***),\n` + | ||
| 261 | + ` ${(rows.length * 0.01).toFixed(2)} false positives, when considering ` + | ||
| 262 | + `a 1% risk acceptance (**, ***),\n` + | ||
| 263 | + ` ${(rows.length * 0.001).toFixed(2)} false positives, when considering ` + | ||
| 264 | + `a 0.1% risk acceptance (***)`, | ||
| 265 | + ); | ||
| 266 | + | ||
| 267 | + // Gate: exit with error if any significant regression exceeds the limit. | ||
| 268 | + if (maxRegression > 0) { | ||
| 269 | + const failures = rows.filter( | ||
| 270 | + (r) => r.stars.trim() !== '' && r.improvement < -maxRegression, | ||
| 271 | + ); | ||
| 272 | + if (failures.length > 0) { | ||
| 273 | + console.log(''); | ||
| 274 | + console.log( | ||
| 275 | + `FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` + | ||
| 276 | + ` showed a statistically significant regression exceeding` + | ||
| 277 | + ` ${maxRegression}%:`, | ||
| 278 | + ); | ||
| 279 | + for (const f of failures) { | ||
| 280 | + console.log(` ${f.name} ${f.improvement.toFixed(2)}%`); | ||
| 281 | + } | ||
| 282 | + process.exitCode = 1; | ||
| 283 | + } | ||
| 284 | + } | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + function printChart(rows, maxNameLen) { | ||
| 288 | + if (rows.length === 0) return; | ||
| 289 | + | ||
| 290 | + // Determine the chart scale from the data. The bar region covers | ||
| 291 | + // the range [-maxAbs, +maxAbs] so the zero line sits in the center. | ||
| 292 | + const barWidth = 40; | ||
| 293 | + const halfWidth = barWidth / 2; | ||
| 294 | + let maxAbs = 0; | ||
| 295 | + for (const row of rows) { | ||
| 296 | + const extent = Math.abs(row.improvement) + row.ci95; | ||
| 297 | + if (extent > maxAbs) maxAbs = extent; | ||
| 298 | + } | ||
| 299 | + if (maxAbs === 0) maxAbs = 1; | ||
| 300 | + | ||
| 301 | + const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length)); | ||
| 302 | + | ||
| 303 | + // Scale axis labels. | ||
| 304 | + const axisLeft = `-${maxAbs.toFixed(1)}%`; | ||
| 305 | + const axisRight = `+${maxAbs.toFixed(1)}%`; | ||
| 306 | + const axisCenter = '0%'; | ||
| 307 | + | ||
| 308 | + // Print axis header. | ||
| 309 | + const labelPad = maxNameLen + 5; | ||
| 310 | + const leftLabel = ' '.repeat(labelPad) + | ||
| 311 | + axisLeft + | ||
| 312 | + ' '.repeat(Math.max(0, halfWidth - axisLeft.length - Math.floor(axisCenter.length / 2))) + | ||
| 313 | + axisCenter + | ||
| 314 | + ' '.repeat(Math.max(0, halfWidth - Math.ceil(axisCenter.length / 2) - axisRight.length)) + | ||
| 315 | + axisRight; | ||
| 316 | + console.log(''); | ||
| 317 | + console.log(leftLabel); | ||
| 318 | + | ||
| 319 | + for (const row of rows) { | ||
| 320 | + const imp = row.improvement; | ||
| 321 | + const ci = row.ci95; | ||
| 322 | + | ||
| 323 | + // Position of the improvement value in the bar region [0, barWidth]. | ||
| 324 | + const center = halfWidth; | ||
| 325 | + const impPos = center + (imp / maxAbs) * halfWidth; | ||
| 326 | + | ||
| 327 | + // CI extent in bar positions. | ||
| 328 | + const ciLeft = center + ((imp - ci) / maxAbs) * halfWidth; | ||
| 329 | + const ciRight = center + ((imp + ci) / maxAbs) * halfWidth; | ||
| 330 | + | ||
| 331 | + // Build the bar character by character. | ||
| 332 | + const chars = []; | ||
| 333 | + for (let x = 0; x < barWidth; x++) { | ||
| 334 | + const pos = x + 0.5; // Center of this character cell. | ||
| 335 | + if (x === Math.floor(center)) { | ||
| 336 | + chars.push('|'); | ||
| 337 | + } else if ((imp >= 0 && pos > center && pos <= impPos) || | ||
| 338 | + (imp < 0 && pos < center && pos >= impPos)) { | ||
| 339 | + chars.push(row.stars ? '\u2588' : '\u2593'); // solid or dark shade | ||
| 340 | + } else if (pos >= ciLeft && pos <= ciRight) { | ||
| 341 | + chars.push('\u2591'); // Light shade for CI region | ||
| 342 | + } else { | ||
| 343 | + chars.push(' '); | ||
| 344 | + } | ||
| 345 | + } | ||
| 346 | + | ||
| 347 | + const label = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)}%`; | ||
| 348 | + const sig = row.stars.trim(); | ||
| 349 | + console.log(`${pad(row.name, maxNameLen)} ${chars.join('')} ${label} ${sig}`); | ||
| 350 | + } | ||
| 351 | + } | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments