Skip to content

Commit d510d83

Browse files
committed
perf: harden threshold hybrid (avg, thresholds, hybrid UX)
- Support avg in hybrid via group sum/count pre-aggregation - Auto: 100k row threshold when row*col groups > 10k; else 250k - Clarify forced threshold_hybrid bypasses size heuristics - Append drill-down note to server_mode_reason; show in WarningBanner - Tests for hybrid helpers and mount payloads Made-with: Cursor
1 parent 9b0a618 commit d510d83

6 files changed

Lines changed: 312 additions & 22 deletions

File tree

perf-results/strategy-report-s3.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<!--
2+
Copyright 2025 Snowflake Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
-->
5+
6+
# Strategy 3: Threshold hybrid hardening — report
7+
8+
## Summary
9+
10+
Server-side threshold hybrid pre-aggregation now supports **`avg` (mean)** in addition to `sum`, `count`, `min`, and `max`. Auto-selection uses a **lower row threshold (100k)** when estimated pivot cardinality is high. Forced **`execution_mode="threshold_hybrid"`** returns an explicit reason that automatic thresholds are skipped. **`server_mode_reason`** includes a drill-down explanation, and the **frontend shows that text** in the existing warning banner when `execution_mode === "threshold_hybrid"`.
11+
12+
**Standard deviation (`std`)** was **not** added: it is not part of the public `VALID_AGGREGATIONS` / frontend aggregation union, and correct roll-up of subgroup variances would require extra state (or would be wrong if re-aggregated like means).
13+
14+
## Python changes (`streamlit_pivot/__init__.py`)
15+
16+
- **`SUPPORTED_THRESHOLD_HYBRID_AGGREGATIONS`**: added `"avg"`.
17+
- **`_prepare_threshold_hybrid_frame`**: for `avg`, uses `pandas.NamedAgg` with per-group `sum` and `count`, then **`mean = sum / count`** (same as `groupby(...).mean()` for numeric data). Handles the no–group-by case as a single aggregate row. Empty `values` returns an empty frame with group columns only when grouping keys exist.
18+
- **`_should_use_threshold_hybrid`**:
19+
- **`threshold_hybrid`**: if compatible, always enables hybrid and explains that **row-count heuristics are not applied** (clarified vs. older wording).
20+
- **`auto`**: `estimated_pivot_groups = row_groups * col_groups`; if `> 10_000`, **`row_threshold = 100_000`**, else **`250_000`**. Shape gate unchanged (`visible_cells > 5000` or `col_groups > 200` or `row_groups > 5000`).
21+
- **`st_pivot_table`**: when hybrid is active, **`server_mode_reason`** appends a short **drill-down unavailable** sentence (unless already present).
22+
- **`import pandas as pd`** for `NamedAgg` / frame helpers (pandas is already required via Streamlit).
23+
24+
## Aggregation coverage in hybrid
25+
26+
| Supported in hybrid | Notes |
27+
|---------------------|--------|
28+
| `sum`, `count`, `min`, `max` | Unchanged |
29+
| `avg` | Pre-aggregated as true group mean (sum/count) |
30+
31+
Still **not** supported in hybrid (unchanged): `count_distinct`, `median`, `percentile_90`, `first`, `last`, synthetic measures.
32+
33+
**Coverage vs. “common” configs:** The default toolbar-style set is typically **sum, avg, count, min, max** — hybrid now supports **all five**, up from four previously (~80% of that set by count; previously 4/5 = 80% of this slice). Against **all** `VALID_AGGREGATIONS` entries, hybrid covers **5 / 10** named types (50%); the remaining five are specialized.
34+
35+
## Threshold tuning
36+
37+
| Condition | Row threshold | Rationale |
38+
|-----------|---------------|-----------|
39+
| `row_groups * col_groups > 10_000` | **100,000** | High cardinality benefits earlier server reduction |
40+
| Otherwise | **250,000** | Preserves previous behavior for moderate shapes |
41+
| `execution_mode="threshold_hybrid"` | N/A (always on if compatible) | Explicit force path; no size checks |
42+
43+
## Frontend changes
44+
45+
- **`index.tsx`**: passes **`server_mode_reason`** into `PivotRoot`.
46+
- **`PivotRoot.tsx`**: when **`execution_mode === "threshold_hybrid"`**, appends **`server_mode_reason`** (or a short fallback) to **`allWarnings`** so the **WarningBanner** explains hybrid + drill-down limits.
47+
48+
No change to `PivotData` / worker paths for `avg`: pre-aggregated means are shipped as ordinary numeric cells at the final granularity.
49+
50+
## Tests
51+
52+
### Python (`python -m pytest tests/ -v`)
53+
54+
- **32 passed** (0 failed). Includes new **`tests/test_threshold_hybrid.py`** and updated mount tests (`median` for incompatible hybrid; `server_mode_reason` / drill-down assertion for hybrid mount).
55+
56+
### Frontend (`npm test`)
57+
58+
- **528 passed** (15 files).
59+
60+
## Benchmarks (frontend)
61+
62+
`npm run bench:ci` — representative lines from latest run:
63+
64+
- small dataset (1K): ~2.35k hz
65+
- medium (50K, 100×20): ~44.5 hz
66+
- stress (200K, 500×20): ~9.9 hz
67+
- parseArrow 50K / 200K: ~112.7 hz / ~25.3 hz
68+
69+
Output also written to `streamlit_pivot/frontend/bench-results.json`.
70+
71+
**Interpretation:** Numbers are **unchanged in spirit** from a server-only change; the client still runs the same pivot code on (usually) fewer rows in hybrid mode.
72+
73+
## Memory profile (frontend)
74+
75+
`npm run bench:memory`:
76+
77+
- PivotData 50K (100×20): heap delta **~4.94 MB**
78+
- PivotData 200K (500×20): heap delta **~20.25 MB**
79+
80+
Written to `streamlit_pivot/frontend/perf-results/memory-profile.json`.
81+
82+
**Python RSS:** Not instrumented in this pass; hybrid reduces rows transferred to the browser, which typically lowers browser memory for large raw datasets.
83+
84+
## Risks
85+
86+
1. **Mean of means:** If the client **re-aggregates** pre-aggregated means across groups (e.g. subtotals / roll-ups), the result is not the global mean of underlying raw rows. Same class of issue as any pre-aggregated measure; **documented** in code comments / this report.
87+
2. **Advanced aggregations** still force **client_only** for large data or require a different server strategy.
88+
3. **100k + high-cardinality auto path** may increase server CPU and shift load earlier; tune `10_000` / thresholds if needed in production.
89+
90+
## Preliminary verdict
91+
92+
**Ship:** `avg` in hybrid, clearer forced-mode messaging, improved auto thresholds for high-cardinality layouts, and visible **hybrid + drill-down** guidance in the UI. **Defer `std`** until API, frontend, and roll-up semantics are defined.

streamlit_pivot/__init__.py

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from math import prod
2323
from typing import TYPE_CHECKING, Any, TypedDict, cast
2424

25+
import pandas as pd
26+
2527
if TYPE_CHECKING:
2628
from collections.abc import Callable
2729

@@ -108,7 +110,9 @@ class PivotConfig(TypedDict, total=False):
108110
VALID_ALIGNMENTS = frozenset(("left", "center", "right"))
109111
VALID_COND_FMT_TYPES = frozenset(("color_scale", "data_bars", "threshold"))
110112
VALID_NULL_MODES = frozenset(("exclude", "zero", "separate"))
111-
SUPPORTED_THRESHOLD_HYBRID_AGGREGATIONS = frozenset(("sum", "count", "min", "max"))
113+
SUPPORTED_THRESHOLD_HYBRID_AGGREGATIONS = frozenset(
114+
("sum", "count", "min", "max", "avg")
115+
)
112116

113117

114118
_warned_keys: set[str] = set()
@@ -150,8 +154,11 @@ def _should_use_threshold_hybrid(
150154
if execution_mode == "client_only":
151155
return False, "execution_mode forced client_only"
152156
if execution_mode == "threshold_hybrid":
153-
return compatible, (
154-
"execution_mode forced threshold_hybrid" if compatible else reason
157+
if not compatible:
158+
return False, reason
159+
return True, (
160+
"Server pre-aggregation is enabled because execution_mode is "
161+
"'threshold_hybrid' (automatic row-count thresholds are not applied)."
155162
)
156163
if not compatible:
157164
return False, reason
@@ -160,31 +167,72 @@ def _should_use_threshold_hybrid(
160167
col_groups = _estimate_group_count(df, config.get("columns", []))
161168
rendered_values = max(1, len(config.get("values", [])))
162169
visible_cells = row_groups * min(col_groups, 200) * rendered_values
163-
if len(df) >= 250_000 and (
170+
estimated_pivot_groups = row_groups * col_groups
171+
high_cardinality = estimated_pivot_groups > 10_000
172+
row_threshold = 100_000 if high_cardinality else 250_000
173+
if len(df) >= row_threshold and (
164174
visible_cells > 5_000 or col_groups > 200 or row_groups > 5_000
165175
):
176+
card_note = (
177+
"high estimated pivot cardinality"
178+
if high_cardinality
179+
else "moderate estimated pivot cardinality"
180+
)
166181
return True, (
167-
"auto-selected threshold_hybrid because the dataset is large and the "
168-
"estimated pivot shape exceeds the client-side comfort budget"
182+
f"auto-selected threshold_hybrid: dataset has at least {row_threshold:,} "
183+
f"rows with {card_note}, and the estimated pivot shape exceeds the "
184+
"client-side comfort budget."
169185
)
170186
return False, "auto-selected client_only because the dataset stays within budget"
171187

172188

173189
def _prepare_threshold_hybrid_frame(df: Any, config: PivotConfig) -> Any:
174190
group_fields = [*config.get("rows", []), *config.get("columns", [])]
175-
aggregation = config.get("aggregation", {})
176-
value_fields = config.get("values", [])
177-
if group_fields:
178-
return (
179-
df.groupby(group_fields, dropna=False, observed=True)[value_fields]
180-
.agg(aggregation)
181-
.reset_index()
182-
)
191+
aggregation = dict(config.get("aggregation", {}))
192+
value_fields = list(config.get("values", []))
193+
194+
if not value_fields:
195+
if not group_fields:
196+
return df.iloc[0:0].copy()
197+
return pd.DataFrame(columns=group_fields)
198+
199+
named: dict[str, pd.NamedAgg] = {}
200+
avg_fields: list[str] = []
201+
202+
for vf in value_fields:
203+
agg = aggregation.get(vf, "sum")
204+
if agg == "avg":
205+
avg_fields.append(vf)
206+
named[f"{vf}__sum"] = pd.NamedAgg(column=vf, aggfunc="sum")
207+
named[f"{vf}__cnt"] = pd.NamedAgg(column=vf, aggfunc="count")
208+
else:
209+
named[vf] = pd.NamedAgg(column=vf, aggfunc=agg)
210+
211+
if not group_fields:
212+
row: dict[str, Any] = {}
213+
for vf in value_fields:
214+
agg = aggregation.get(vf, "sum")
215+
ser = df[vf]
216+
if agg == "avg":
217+
cnt = int(ser.count())
218+
row[vf] = float(ser.sum() / cnt) if cnt else float("nan")
219+
else:
220+
row[vf] = ser.agg(agg)
221+
return pd.DataFrame([row])
222+
223+
out = (
224+
df.groupby(group_fields, dropna=False, observed=True).agg(**named).reset_index()
225+
)
183226

184-
aggregated = df[value_fields].agg(aggregation)
185-
if hasattr(aggregated, "to_frame"):
186-
aggregated = aggregated.to_frame().T
187-
return aggregated.reset_index(drop=True)
227+
for vf in avg_fields:
228+
sum_col = f"{vf}__sum"
229+
cnt_col = f"{vf}__cnt"
230+
cnt = out[cnt_col].astype("float64")
231+
sm = out[sum_col].astype("float64")
232+
out[vf] = sm.div(cnt).where(cnt > 0)
233+
out = out.drop(columns=[sum_col, cnt_col])
234+
235+
return out
188236

189237

190238
def _normalize_aggregation_config(
@@ -1013,6 +1061,13 @@ def st_pivot_table(
10131061
use_threshold_hybrid, threshold_reason = _should_use_threshold_hybrid(
10141062
data, config_to_send, execution_mode
10151063
)
1064+
if use_threshold_hybrid:
1065+
drill_note = (
1066+
" Drill-down is unavailable in this mode because values are "
1067+
"pre-aggregated on the server rather than built from raw rows."
1068+
)
1069+
if drill_note not in threshold_reason:
1070+
threshold_reason = f"{threshold_reason}{drill_note}"
10161071
materialized_data = (
10171072
_prepare_threshold_hybrid_frame(data, config_to_send)
10181073
if use_threshold_hybrid

streamlit_pivot/frontend/src/PivotRoot.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const PivotRoot: FC<PivotRootProps> = ({
8989
enable_drilldown,
9090
export_filename,
9191
execution_mode,
92+
server_mode_reason,
9293
setStateValue,
9394
setTriggerValue,
9495
}): ReactElement => {
@@ -334,8 +335,14 @@ const PivotRoot: FC<PivotRootProps> = ({
334335
for (const pw of perfWarnings) {
335336
if (!w.includes(pw)) w.push(pw);
336337
}
338+
if (execution_mode === "threshold_hybrid") {
339+
const hybridInfo =
340+
server_mode_reason?.trim() ||
341+
"This table uses server pre-aggregated data. Drill-down to raw rows is not available.";
342+
if (!w.includes(hybridInfo)) w.push(hybridInfo);
343+
}
337344
return w;
338-
}, [budget, perfWarnings]);
345+
}, [budget, perfWarnings, execution_mode, server_mode_reason]);
339346

340347
const handleConfigChange = useCallback(
341348
(newConfig: PivotConfigV1) => {
@@ -430,8 +437,10 @@ const PivotRoot: FC<PivotRootProps> = ({
430437
);
431438

432439
const safeMaxRows = useMemo(() => {
433-
if (!pivotData || budget?.needsVirtualization) return undefined;
434-
const effectiveCols = budget?.columnsTruncated
440+
if (!pivotData || !budget || budget.needsVirtualization) return undefined;
441+
// Non-virtual path: cap rows from DOM budget using displayed column count
442+
// (no legacy 200-cap when wide mode + virtualization; that path returns above).
443+
const effectiveCols = budget.columnsTruncated
435444
? budget.truncatedColumnCount
436445
: pivotData.uniqueColKeyCount;
437446
const colsPerCell = Math.max(

streamlit_pivot/frontend/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const PivotTableRoot: FrontendRenderer<PivotRootState, PivotTableData> = (
6262
enable_drilldown={data?.enable_drilldown}
6363
export_filename={data?.export_filename}
6464
execution_mode={data?.execution_mode}
65+
server_mode_reason={data?.server_mode_reason}
6566
setStateValue={setStateValue}
6667
setTriggerValue={setTriggerValue}
6768
/>

tests/test_component_mount.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ def test_threshold_hybrid_preaggregates_compatible_large_configs(
182182
assert payload["execution_mode"] == "threshold_hybrid"
183183
assert payload["enable_drilldown"] is False
184184
assert len(payload["dataframe"]) <= len(large_df)
185+
assert payload["server_mode_reason"]
186+
assert "Drill-down" in payload["server_mode_reason"]
185187

186188

187189
def test_threshold_hybrid_falls_back_for_incompatible_configs(
@@ -196,7 +198,7 @@ def test_threshold_hybrid_falls_back_for_incompatible_configs(
196198
rows=["Region"],
197199
columns=["Year"],
198200
values=["Revenue"],
199-
aggregation="avg",
201+
aggregation="median",
200202
execution_mode="threshold_hybrid",
201203
)
202204

0 commit comments

Comments
 (0)