diff --git a/benchmark-bot/src/render.ts b/benchmark-bot/src/render.ts
index 00bb695..fec56ee 100644
--- a/benchmark-bot/src/render.ts
+++ b/benchmark-bot/src/render.ts
@@ -134,9 +134,18 @@ function renderComparisonBlock(block: string, blockLimit: number): string {
return renderPreformatted(block, blockLimit);
}
- const summary = `${lines[0]}\n${lines[totalIndex]!.trimStart()}`;
+ const summaryIndexes = new Set([0, totalIndex]);
+ for (const [index, line] of lines.entries()) {
+ if (line.trimStart().startsWith("TASKS:")) {
+ summaryIndexes.add(index);
+ }
+ }
+ const summary = lines
+ .filter((_line, index) => summaryIndexes.has(index))
+ .map((line) => line.trimStart())
+ .join("\n");
const details = lines
- .filter((_line, index) => index !== 0 && index !== totalIndex)
+ .filter((_line, index) => !summaryIndexes.has(index))
.join("\n");
const summaryPrefix = "
";
const summarySuffix = "
\n\n";
diff --git a/benchmark-bot/test/worker.test.ts b/benchmark-bot/test/worker.test.ts
index 5ab2876..69052f8 100644
--- a/benchmark-bot/test/worker.test.ts
+++ b/benchmark-bot/test/worker.test.ts
@@ -39,6 +39,7 @@ const TIMINGS = {
const COMPARISON = `=== Comparing tpch/sf1 results 'base' [prev] with 'head' [new] ===
q1: prev= 100 ms, new= 120 ms, diff=1.20 slower ✖
q2: prev= 200 ms, new= 150 ms, diff=1.33 faster ✔
+ TASKS: prev=20.0, new=18.0, diff=2.0 fewer (10.0%) (sum of per-query averages)
TOTAL: prev=300 ms, new=270 ms, diff=1.11 faster ✅`;
test("reports a completed comparison and consumes the job", async () => {
@@ -75,6 +76,8 @@ test("reports a completed comparison and consumes the job", async () => {
assert.match(comments[1]!, /Progress 3\/10/);
assert.match(comments[1]!, /Deploying the base revision/);
assert.match(comments[2]!, /TOTAL: prev=300 ms, new=270 ms/);
+ assert.match(comments[2]!, /TASKS: prev=20\.0, new=18\.0/);
+ assert.match(comments[2]!, /TASKS:.*TOTAL:.*<\/pre>\s*/s);
assert.match(comments[2]!, /Show full query output/);
assert.equal(comments[2]!.match(/=== Comparing/g)?.length, 1);
assert.equal(comments[2]!.match(/TOTAL:/g)?.length, 1);
diff --git a/benchmarks-remote/src/lib/results.ts b/benchmarks-remote/src/lib/results.ts
index 7153805..0acd7be 100644
--- a/benchmarks-remote/src/lib/results.ts
+++ b/benchmarks-remote/src/lib/results.ts
@@ -270,6 +270,8 @@ export class BenchmarkRun {
];
let totalTimePrev = 0;
let totalTimeNew = 0;
+ let totalTasksPrev = 0;
+ let totalTasksNew = 0;
const statsQErrorP50Prev: number[] = [];
const statsQErrorP50New: number[] = [];
const statsQErrorP95Prev: number[] = [];
@@ -284,6 +286,12 @@ export class BenchmarkRun {
if (timePrev !== undefined && timeNew !== undefined) {
totalTimePrev += timePrev;
totalTimeNew += timeNew;
+ const tasksPrev = prevQuery.averageTasks();
+ const tasksNew = query.averageTasks();
+ if (tasksPrev !== undefined && tasksNew !== undefined) {
+ totalTasksPrev += tasksPrev;
+ totalTasksNew += tasksNew;
+ }
statsQErrorP50Prev.push(
...prevQuery.iterations.flatMap((iteration) =>
iteration.statsQErrorP50 === undefined
@@ -342,6 +350,9 @@ export class BenchmarkRun {
);
if (qErrorP50) lines.push(qErrorP50);
if (qErrorP95) lines.push(qErrorP95);
+ lines.push(
+ `${taskComparison("TASKS", totalTasksPrev, totalTasksNew)} (sum of per-query averages)`,
+ );
lines.push(
`${"TOTAL".padStart(8)}: prev=${totalTimePrev.toString()} ms, new=${totalTimeNew.toString()} ms, diff=${factor.toFixed(2)} ${tag} ${emoji}`,
);
@@ -396,6 +407,16 @@ export class BenchResult {
return this.p50();
}
+ averageTasks(): number | undefined {
+ const values = this.iterations
+ .filter((iteration) => !iteration.error)
+ .map((iteration) => iteration.tasks);
+ if (values.length === 0) {
+ return undefined;
+ }
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+ }
+
comparison(prevQuery: BenchResult): string {
const prevError = prevQuery.iterations.find((value) => value.error)?.error;
const newError = this.iterations.find((value) => value.error)?.error;
@@ -412,6 +433,8 @@ export class BenchResult {
const p50Prev = prevQuery.p50();
const p50 = this.p50();
+ const tasksPrev = prevQuery.averageTasks();
+ const tasks = this.averageTasks();
let factor: number;
let tag: string;
@@ -426,7 +449,11 @@ export class BenchResult {
emoji = factor >= QUERY_HIGHLIGHT_THRESHOLD ? "❌" : "✖";
}
- return `${this.id.padStart(8)}: prev=${p50Prev.toString().padStart(4)} ms, new=${p50.toString().padStart(4)} ms, diff=${factor.toFixed(2)} ${tag} ${emoji}`;
+ const timeComparison = `${this.id.padStart(8)}: prev=${p50Prev.toString().padStart(4)} ms, new=${p50.toString().padStart(4)} ms, diff=${factor.toFixed(2)} ${tag} ${emoji}`;
+ if (tasksPrev === undefined || tasks === undefined) {
+ return timeComparison;
+ }
+ return `${timeComparison}, ${taskComparison("tasks", tasksPrev, tasks).trimStart()}`;
}
store(): void {
@@ -580,6 +607,24 @@ function qErrorComparison(
return undefined;
}
+function taskComparison(label: string, previous: number, next: number): string {
+ let difference: string;
+ if (next === previous) {
+ difference = "no change";
+ } else if (previous === 0) {
+ difference = `${formatTasks(next)} more`;
+ } else {
+ const absolute = Math.abs(next - previous);
+ const percentage = (absolute / previous) * 100;
+ difference = `${formatTasks(absolute)} ${next < previous ? "fewer" : "more"} (${percentage.toFixed(1)}%)`;
+ }
+ return `${label.padStart(8)}: prev=${formatTasks(previous)}, new=${formatTasks(next)}, diff=${difference}`;
+}
+
+function formatTasks(value: number): string {
+ return value.toFixed(1);
+}
+
function median(values: number[]): number | undefined {
if (values.length === 0) {
return undefined;
diff --git a/benchmarks-remote/sum-tasks.sh b/benchmarks-remote/sum-tasks.sh
new file mode 100755
index 0000000..a66d7d0
--- /dev/null
+++ b/benchmarks-remote/sum-tasks.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+result_dir=${1:?usage: sum-tasks.sh RESULT_DIRECTORY}
+
+if [[ ! -d ${result_dir} ]]; then
+ echo "Result directory does not exist: ${result_dir}" >&2
+ exit 1
+fi
+
+shopt -s nullglob
+result_files=("${result_dir}"/q*.json)
+if (( ${#result_files[@]} == 0 )); then
+ echo "No q*.json result files found in ${result_dir}" >&2
+ exit 1
+fi
+
+jq -s '
+ [.[].iterations[] | select(.error == null) | .tasks] | add // 0
+' "${result_files[@]}"
diff --git a/benchmarks-remote/test/compare-files.test.ts b/benchmarks-remote/test/compare-files.test.ts
index faf6b46..537ff98 100644
--- a/benchmarks-remote/test/compare-files.test.ts
+++ b/benchmarks-remote/test/compare-files.test.ts
@@ -13,9 +13,10 @@ function result(
resultName: string,
query: string,
elapsed: number,
+ tasks = 1,
): BenchResult {
const value = new BenchResult(dataset, resultName, query, root);
- value.iterations.push({ elapsed, plan: "", rowCount: 1, tasks: 1 });
+ value.iterations.push({ elapsed, plan: "", rowCount: 1, tasks });
return value;
}
@@ -47,12 +48,48 @@ test("builds comparisons from stored completed runs", () => {
const comparison = compareStoredResults("tpch/sf1", "base", "head", root);
assert.match(comparison, /^=== Comparing tpch\/sf1 results/);
assert.match(comparison, /q1: prev= 100 ms, new= 90 ms/);
+ assert.match(comparison, /tasks: prev=1\.0, new=1\.0, diff=no change/);
+ assert.match(comparison, /TASKS: prev=1\.0, new=1\.0, diff=no change/);
assert.match(comparison, /TOTAL: prev=100 ms, new=90 ms/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+test("compares per-query average tasks and sums them across queries", () => {
+ const root = fs.mkdtempSync(
+ path.join(os.tmpdir(), "benchmark-file-compare-"),
+ );
+ fs.mkdirSync(path.join(root, "tpch", "sf1"), { recursive: true });
+
+ try {
+ const baseQ1 = result(root, "tpch/sf1", "base", "q1", 100, 6);
+ baseQ1.iterations.push({ elapsed: 101, plan: "", rowCount: 1, tasks: 6 });
+ const headQ1 = result(root, "tpch/sf1", "head", "q1", 90, 4);
+ headQ1.iterations.push({ elapsed: 91, plan: "", rowCount: 1, tasks: 6 });
+ storeRun(root, "tpch/sf1", "base", [
+ baseQ1,
+ result(root, "tpch/sf1", "base", "q2", 200, 10),
+ ]);
+ storeRun(root, "tpch/sf1", "head", [
+ headQ1,
+ result(root, "tpch/sf1", "head", "q2", 180, 10),
+ ]);
+
+ const comparison = compareStoredResults("tpch/sf1", "base", "head", root);
+ assert.match(
+ comparison,
+ /q1:.*tasks: prev=6\.0, new=5\.0, diff=1\.0 fewer \(16\.7%\)/,
+ );
+ assert.match(
+ comparison,
+ /TASKS: prev=16\.0, new=15\.0, diff=1\.0 fewer \(6\.3%\) \(sum of per-query averages\)/,
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("keeps legacy per-query result directories comparable", () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "benchmark-file-compare-"),
diff --git a/benchmarks-remote/test/comparison-thresholds.test.ts b/benchmarks-remote/test/comparison-thresholds.test.ts
index 964ec63..afa6ac9 100644
--- a/benchmarks-remote/test/comparison-thresholds.test.ts
+++ b/benchmarks-remote/test/comparison-thresholds.test.ts
@@ -23,9 +23,9 @@ test("highlights individual queries at 1.5x", () => {
result("base", 150),
);
- assert.match(normalRegression, /1\.49 slower ✖$/);
- assert.match(highlightedRegression, /1\.50 slower ❌$/);
- assert.match(highlightedImprovement, /1\.50 faster ✅$/);
+ assert.match(normalRegression, /1\.49 slower ✖, tasks:/);
+ assert.match(highlightedRegression, /1\.50 slower ❌, tasks:/);
+ assert.match(highlightedImprovement, /1\.50 faster ✅, tasks:/);
});
test("highlights aggregate totals at 1.1x and prints TOTAL last", () => {
diff --git a/benchmarks-remote/test/results.test.ts b/benchmarks-remote/test/results.test.ts
index 73d5614..78a7c48 100644
--- a/benchmarks-remote/test/results.test.ts
+++ b/benchmarks-remote/test/results.test.ts
@@ -122,3 +122,27 @@ test("reports malformed previous-run manifests instead of treating them as absen
/Invalid previous run manifest/,
);
});
+
+test("averages task counts across successful measured iterations", () => {
+ const value = new BenchResult("tpch/sf1", "head", "q1");
+ value.iterations.push(
+ { elapsed: 10, plan: "", rowCount: 1, tasks: 4 },
+ { elapsed: 11, plan: "", rowCount: 1, tasks: 6 },
+ { elapsed: 0, plan: "", rowCount: 0, tasks: 0, error: "failed" },
+ );
+
+ assert.equal(value.averageTasks(), 5);
+});
+
+test("does not report an average task count without a successful iteration", () => {
+ const value = new BenchResult("tpch/sf1", "head", "q1");
+ value.iterations.push({
+ elapsed: 0,
+ plan: "",
+ rowCount: 0,
+ tasks: 0,
+ error: "failed",
+ });
+
+ assert.equal(value.averageTasks(), undefined);
+});