Skip to content

Commit 3dfc5ef

Browse files
committed
Add performance comparison
1 parent cb6ff4b commit 3dfc5ef

10 files changed

Lines changed: 207 additions & 204 deletions

File tree

.github/workflows/python-tests.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,22 @@ jobs:
4848
run: |
4949
python -m pytest tests/ --cov=streamlit_pivot --cov-report=term-missing --cov-report=html
5050
51+
- name: Restore Python benchmark baseline
52+
uses: actions/cache/restore@v4
53+
with:
54+
path: tests/perf_baseline.json
55+
key: perf-baseline-python-v2-${{ runner.os }}
56+
5157
- name: Run Python performance harness
52-
run: python tests/perf_benchmark.py
58+
run: python tests/perf_benchmark.py --baseline-path tests/perf_baseline.json
59+
60+
- name: Save Python benchmark baseline
61+
if: github.ref == 'refs/heads/main' && success()
62+
run: python tests/perf_benchmark.py --update-baseline --baseline-path tests/perf_baseline.json
63+
64+
- name: Cache Python benchmark baseline
65+
if: github.ref == 'refs/heads/main' && success()
66+
uses: actions/cache/save@v4
67+
with:
68+
path: tests/perf_baseline.json
69+
key: perf-baseline-python-v2-${{ runner.os }}

.github/workflows/ts-tests.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,29 @@ jobs:
4747
run: |
4848
cd streamlit_pivot/frontend
4949
npm test -- --coverage
50+
51+
- name: Restore TS benchmark baseline
52+
uses: actions/cache/restore@v4
53+
with:
54+
path: streamlit_pivot/frontend/bench-baseline.json
55+
key: perf-baseline-ts-v2-${{ runner.os }}
56+
5057
- name: Run frontend bench regression check
5158
run: |
5259
cd streamlit_pivot/frontend
5360
npm run bench:check
61+
62+
- name: Save TS benchmark baseline
63+
if: github.ref == 'refs/heads/main' && success()
64+
run: cp streamlit_pivot/frontend/bench-results.json streamlit_pivot/frontend/bench-baseline.json
65+
66+
- name: Cache TS benchmark baseline
67+
if: github.ref == 'refs/heads/main' && success()
68+
uses: actions/cache/save@v4
69+
with:
70+
path: streamlit_pivot/frontend/bench-baseline.json
71+
key: perf-baseline-ts-v2-${{ runner.os }}
72+
5473
- name: Audit frontend licenses
5574
run: python scripts/audit_frontend_licenses.py
5675
- name: Validate NOTICES

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,8 @@ coverage/
5555
# uv lockfiles (regenerated locally)
5656
uv.lock
5757
htmlcov/
58+
59+
# Benchmark baselines & results (environment-specific, managed by CI cache)
60+
bench-baseline.json
61+
bench-results.json
62+
perf_baseline.json

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Returns a `PivotTableResult` dict containing the current `config` state.
114114
| `frozen_columns` | `list[str] \| None` | `None` | Column names that cannot be removed from their toolbar zone. |
115115
| `sorters` | `dict[str, list[str]] \| None` | `None` | Custom sort orderings per dimension. Maps column name to ordered list of values. |
116116
| `menu_limit` | `int \| None` | `None` | Max items in the header-menu filter checklist. Defaults to 50. |
117+
| `execution_mode` | `str` | `"auto"` | Performance execution mode. See [Execution Mode](#execution-mode). |
117118

118119
---
119120

@@ -456,6 +457,35 @@ result = st_pivot_table(
456457
- Close with the **×** button or by pressing **Escape**.
457458
- Set `enable_drilldown=False` to disable (the `on_cell_click` callback still fires).
458459

460+
### Execution Mode
461+
462+
Controls how pivot aggregation is performed for large datasets. By default (`"auto"`), the component computes everything client-side unless the dataset is large enough to benefit from server-side pre-aggregation.
463+
464+
| Mode | Value | Description |
465+
|------|-------|-------------|
466+
| Auto | `"auto"` | Client-side unless the dataset exceeds row/cardinality thresholds (default) |
467+
| Client Only | `"client_only"` | Always send raw rows to the frontend |
468+
| Threshold Hybrid | `"threshold_hybrid"` | Force server-side pre-aggregation when the config is compatible |
469+
470+
```python
471+
st_pivot_table(
472+
df,
473+
key="large_dataset_example",
474+
rows=["Region", "Category"],
475+
columns=["Year"],
476+
values=["Revenue"],
477+
execution_mode="auto",
478+
)
479+
```
480+
481+
**Auto thresholds:** In `"auto"` mode, server-side pre-aggregation activates when the dataset has at least 100K rows (high-cardinality layouts) or 250K rows (moderate layouts) and the estimated pivot shape exceeds the client-side comfort budget.
482+
483+
**Supported aggregations:** `sum`, `count`, `min`, `max`, and `avg`. Configs using other aggregations (e.g. `median`, `count_distinct`) fall back to client-side computation automatically.
484+
485+
**Limitations:**
486+
- Drill-down is disabled in hybrid mode because values are pre-aggregated on the server rather than built from raw rows.
487+
- Synthetic measures are not supported in hybrid mode (falls back to client-side).
488+
459489
### Locked Mode
460490

461491
Use `locked=True` for a viewer-mode experience with exploration enabled. Toolbar config controls stay locked so end-users cannot change rows, columns, values, per-measure aggregation, or settings toggles. Reset, Swap, and config import/export are hidden, while data export remains available and the Settings gear stays visible for read-only display status plus Expand/Collapse All group controls. Header-menu sorting, filtering, and `Show Values As` remain available, and drill-down still works.

streamlit_pivot/frontend/bench-baseline.json

Lines changed: 0 additions & 114 deletions
This file was deleted.

streamlit_pivot/frontend/scripts/check-bench-regression.mjs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
* npm run bench:save-baseline # save current as baseline
2828
*/
2929

30-
import { readFileSync, existsSync } from "node:fs";
30+
import { readFileSync, appendFileSync, existsSync } from "node:fs";
3131
import { resolve, dirname } from "node:path";
3232
import { fileURLToPath } from "node:url";
3333

@@ -36,7 +36,9 @@ const root = resolve(__dirname, "..");
3636

3737
const REGRESSION_THRESHOLD = 0.20; // 20%
3838

39-
const baselinePath = resolve(root, "bench-baseline.json");
39+
const baselinePath = process.env.BENCH_BASELINE_PATH
40+
? resolve(process.env.BENCH_BASELINE_PATH)
41+
: resolve(root, "bench-baseline.json");
4042
const resultsPath = resolve(root, "bench-results.json");
4143

4244
if (!existsSync(baselinePath)) {
@@ -83,6 +85,7 @@ if (baseMap.size === 0) {
8385

8486
let failures = 0;
8587
let checked = 0;
88+
const rows = [];
8689

8790
for (const [name, baselineMedian] of baseMap) {
8891
const currentMedian = resultMap.get(name);
@@ -106,13 +109,28 @@ for (const [name, baselineMedian] of baseMap) {
106109
` ${symbol} ${name}: ${baselineMedian.toFixed(2)}ms → ${currentMedian.toFixed(2)}ms (${pctChange > 0 ? "+" : ""}${pctChange}%) [${status}]`,
107110
);
108111

112+
rows.push({ name, baselineMedian, currentMedian, pctChange, status, symbol });
113+
109114
if (status === "FAIL") {
110115
failures++;
111116
}
112117
}
113118

114119
console.log(`\nChecked ${checked} benchmarks, ${failures} regression(s).`);
115120

121+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
122+
if (summaryPath && rows.length > 0) {
123+
const header = `### Frontend Benchmark Results\n\n` +
124+
`| Benchmark | Baseline | Current | Change | Status |\n` +
125+
`|-----------|----------|---------|--------|--------|\n`;
126+
const body = rows.map((r) => {
127+
const pct = `${r.pctChange > 0 ? "+" : ""}${r.pctChange}%`;
128+
return `| ${r.name} | ${r.baselineMedian.toFixed(2)} ms | ${r.currentMedian.toFixed(2)} ms | ${pct} | ${r.symbol} ${r.status} |`;
129+
}).join("\n");
130+
const footer = `\n\n> Threshold: ${REGRESSION_THRESHOLD * 100}% · ${checked} benchmarks checked · ${failures} regression(s)\n`;
131+
appendFileSync(summaryPath, header + body + footer);
132+
}
133+
116134
if (failures > 0) {
117135
console.error(
118136
`\nFAILED: ${failures} benchmark(s) regressed by >${REGRESSION_THRESHOLD * 100}%.`,

streamlit_pivot/frontend/src/engine/PivotData.bench.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,10 @@ describe("PivotData computation benchmarks", () => {
105105
new PivotData(multiVal.records, multiVal.config);
106106
});
107107

108-
// Stress: 200,000 rows, 500 regions, 20 years -> 10,000 unique cells
109-
const stress = generateRecords(200000, 500, 20);
110-
bench("stress dataset (200K rows, 500x20 grid)", () => {
111-
new PivotData(stress.records, stress.config);
108+
// Large: 200,000 rows, 500 regions, 20 years -> 10,000 unique cells
109+
const large = generateRecords(200000, 500, 20);
110+
bench("large dataset (200K rows, 500x20 grid)", () => {
111+
new PivotData(large.records, large.config);
112112
});
113113

114114
// Wide columns: 50,000 rows, 20 regions, 1,000 years -> 20,000 unique cells

streamlit_pivot/frontend/src/engine/PivotData.scaled.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,10 @@ describe("Scaled Correctness — Large (200K rows)", () => {
332332
const { elapsedMs } = measureSync(
333333
() => new PivotData(largeRecords, config),
334334
);
335-
expect(elapsedMs).toBeLessThan(DEFAULT_BUDGETS.maxComputeMs);
335+
// CI runners are ~2x slower than dev machines; apply headroom multiplier
336+
// so the test validates the right order-of-magnitude without flaking.
337+
const CI_HEADROOM = 2;
338+
expect(elapsedMs).toBeLessThan(DEFAULT_BUDGETS.maxComputeMs * CI_HEADROOM);
336339
});
337340

338341
it.skipIf(skip)(

tests/perf_baseline.json

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)