diff --git a/package.json b/package.json index ee144e8dea..b7922987e5 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "size-limit": [ { "path": "packages/table-core/dist/index.js", - "limit": "20 KB" + "limit": "25 KB" } ], "devDependencies": { diff --git a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts index 88f952fafb..ef3da12d96 100644 --- a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts +++ b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts @@ -5,10 +5,10 @@ import { table_autoResetExpanded } from '../row-expanding/rowExpandingFeature.ut import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils' import { aggregateColumnValue, - normalizeAggregationRows, + normalizeUniqueAggregationRows, } from '../row-aggregation/rowAggregationFeature.utils' -import { row_getGroupingValue } from './columnGroupingFeature.utils' import type { Row_ColumnGrouping } from './columnGroupingFeature.types' +import type { Column_Internal } from '../../types/Column' import type { TableFeatures } from '../../types/TableFeatures' import type { RowModel } from '../../core/row-models/coreRowModelsFeature.types' import type { Table, Table_Internal } from '../../types/Table' @@ -103,7 +103,7 @@ function _createGroupedRowModel< const columnId = existingGrouping[depth] as string // Group the rows together for this level - const rowGroupsMap = groupBy(rows, columnId) + const rowGroupsMap = groupBy(table, rows, columnId) // Perform aggregations for each group const aggregatedGroupedRows = Array.from(rowGroupsMap.entries()).map( @@ -118,7 +118,13 @@ function _createGroupedRowModel< subRow.parentId = id }) - const leafRows = normalizeAggregationRows(groupedRows, Infinity) + // Rows produced by groupBy are disjoint members of the pre-grouped + // row tree, so the duplicate-id guard is unnecessary; flat groups + // reuse the partition array as-is. + const leafRows = normalizeUniqueAggregationRows( + groupedRows, + Infinity, + ) as Array> const row = constructRow( table, @@ -171,6 +177,7 @@ function _createGroupedRowModel< column, groupingRow: row, rows: groupedRows, + uniqueRows: true, }) return cache[colId] }, @@ -203,19 +210,49 @@ function _createGroupedRowModel< } function groupBy( + table: Table_Internal, rows: Array>, columnId: string, ) { const groupMap = new Map>>() - return rows.reduce((map, row) => { - const resKey = `${row_getGroupingValue(row, columnId)}` - const previous = map.get(resKey) + // Resolve the column once instead of per row: `table.getColumn` goes + // through the memoized-API dispatcher, which is far too expensive to sit + // inside this per-row loop. The branches below mirror + // `row_getGroupingValue`'s caching contract exactly. + const column = table_getColumn(table, columnId) as + | Column_Internal + | undefined + const getGroupingValue = column?.columnDef.getGroupingValue + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]! + let groupingValue + if (getGroupingValue) { + const cache = (row as any)._groupingValuesCache as + | Record + | undefined + if (cache && hasOwn(cache, columnId)) { + groupingValue = cache[columnId] + } else if (cache) { + groupingValue = cache[columnId] = getGroupingValue( + row.original, + row.index, + row, + ) + } + } else { + groupingValue = row.getValue(columnId) + } + + const resKey = `${groupingValue}` + const previous = groupMap.get(resKey) if (!previous) { - map.set(resKey, [row]) + groupMap.set(resKey, [row]) } else { previous.push(row) } - return map - }, groupMap) + } + + return groupMap } diff --git a/packages/table-core/src/features/row-aggregation/aggregationFns.ts b/packages/table-core/src/features/row-aggregation/aggregationFns.ts index 114ab64aae..c7fbfd05bc 100755 --- a/packages/table-core/src/features/row-aggregation/aggregationFns.ts +++ b/packages/table-core/src/features/row-aggregation/aggregationFns.ts @@ -1,5 +1,4 @@ import { constructAggregationFn } from './rowAggregationFeature.types' -import type { AggregationContext } from './rowAggregationFeature.types' type RangeValue = Date | number @@ -23,19 +22,9 @@ function compareRangeValues(left: RangeValue, right: RangeValue) { return leftValue - rightValue } -function collectRangeValues(context: AggregationContext) { - const values: Array = [] - let kind: 'date' | 'number' | undefined - - for (let i = 0; i < context.rows.length; i++) { - const value = context.getValue(context.rows[i]!) - const valueKind = getRangeKind(value) - if (!valueKind) continue - kind ??= valueKind - if (valueKind === kind) values.push(value as RangeValue) - } - - return values +/** Comparable representation of a range value; `Date`s compare by time. */ +function toRangeNumber(value: RangeValue): number { + return value instanceof Date ? value.getTime() : value } /** @@ -49,9 +38,10 @@ export const aggregationFn_sum = constructAggregationFn< number >({ aggregate: (context) => { + const rows = context.rows let sum = 0 - for (let i = 0; i < context.rows.length; i++) { - const value = context.getValue(context.rows[i]!) + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) sum += typeof value === 'number' ? value : 0 } return sum @@ -77,10 +67,23 @@ export const aggregationFn_min = constructAggregationFn< RangeValue | undefined >({ aggregate: (context) => { - const values = collectRangeValues(context) - let result = values[0] - for (let i = 1; i < values.length; i++) { - if (compareRangeValues(values[i]!, result!) < 0) result = values[i] + const rows = context.rows + let kind: 'date' | 'number' | undefined + let result: RangeValue | undefined + let resultNumber = 0 + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) + const valueKind = getRangeKind(value) + if (!valueKind || (kind !== undefined && valueKind !== kind)) continue + const valueNumber = toRangeNumber(value as RangeValue) + if (kind === undefined) { + kind = valueKind + result = value as RangeValue + resultNumber = valueNumber + } else if (valueNumber - resultNumber < 0) { + result = value as RangeValue + resultNumber = valueNumber + } } return result }, @@ -113,10 +116,23 @@ export const aggregationFn_max = constructAggregationFn< RangeValue | undefined >({ aggregate: (context) => { - const values = collectRangeValues(context) - let result = values[0] - for (let i = 1; i < values.length; i++) { - if (compareRangeValues(values[i]!, result!) > 0) result = values[i] + const rows = context.rows + let kind: 'date' | 'number' | undefined + let result: RangeValue | undefined + let resultNumber = 0 + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) + const valueKind = getRangeKind(value) + if (!valueKind || (kind !== undefined && valueKind !== kind)) continue + const valueNumber = toRangeNumber(value as RangeValue) + if (kind === undefined) { + kind = valueKind + result = value as RangeValue + resultNumber = valueNumber + } else if (valueNumber - resultNumber > 0) { + result = value as RangeValue + resultNumber = valueNumber + } } return result }, @@ -150,15 +166,33 @@ export const aggregationFn_extent = constructAggregationFn< [RangeValue | undefined, RangeValue | undefined] >({ aggregate: (context) => { - const values = collectRangeValues(context) - if (!values.length) return [undefined, undefined] - let min = values[0]! - let max = values[0]! - for (let i = 1; i < values.length; i++) { - const value = values[i]! - if (compareRangeValues(value, min) < 0) min = value - if (compareRangeValues(value, max) > 0) max = value + const rows = context.rows + let kind: 'date' | 'number' | undefined + let min: RangeValue | undefined + let max: RangeValue | undefined + let minNumber = 0 + let maxNumber = 0 + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) + const valueKind = getRangeKind(value) + if (!valueKind || (kind !== undefined && valueKind !== kind)) continue + const valueNumber = toRangeNumber(value as RangeValue) + if (kind === undefined) { + kind = valueKind + min = max = value as RangeValue + minNumber = maxNumber = valueNumber + } else { + if (valueNumber - minNumber < 0) { + min = value as RangeValue + minNumber = valueNumber + } + if (valueNumber - maxNumber > 0) { + max = value as RangeValue + maxNumber = valueNumber + } + } } + if (kind === undefined) return [undefined, undefined] return [min, max] }, merge: ({ subRowResults }) => { @@ -205,10 +239,11 @@ export const aggregationFn_mean = constructAggregationFn< number | undefined >({ aggregate: (context) => { + const rows = context.rows let count = 0 let sum = 0 - for (let i = 0; i < context.rows.length; i++) { - const value = context.getValue(context.rows[i]!) + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) if (value == null) continue const numberValue = typeof value === 'number' ? value : +value if (!Number.isNaN(numberValue)) { @@ -231,10 +266,11 @@ export const aggregationFn_median = constructAggregationFn< number | undefined >({ aggregate: (context) => { - if (!context.rows.length) return undefined - const values = new Array(context.rows.length) - for (let i = 0; i < context.rows.length; i++) { - const value = context.getValue(context.rows[i]!) + const rows = context.rows + if (!rows.length) return undefined + const values = new Array(rows.length) + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) if (typeof value !== 'number') return undefined values[i] = value } @@ -254,9 +290,10 @@ export const aggregationFn_unique = constructAggregationFn< Array >({ aggregate: (context) => { + const rows = context.rows const values = new Set() - for (let i = 0; i < context.rows.length; i++) { - values.add(context.getValue(context.rows[i]!)) + for (let i = 0; i < rows.length; i++) { + values.add(context.getValue(rows[i]!)) } return Array.from(values) }, @@ -270,9 +307,10 @@ export const aggregationFn_uniqueCount = constructAggregationFn< number >({ aggregate: (context) => { + const rows = context.rows const values = new Set() - for (let i = 0; i < context.rows.length; i++) { - values.add(context.getValue(context.rows[i]!)) + for (let i = 0; i < rows.length; i++) { + values.add(context.getValue(rows[i]!)) } return values.size }, diff --git a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts index 7bd8e1db0b..3c69aba06f 100644 --- a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts +++ b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts @@ -1,4 +1,4 @@ -import { assignPrototypeAPIs, makeObjectMap } from '../../utils' +import { assignPrototypeAPIs } from '../../utils' import { cell_getIsAggregated, column_getAggregationFns, @@ -53,8 +53,4 @@ export const rowAggregationFeature: TableFeature = { ;(column as any)._aggregationValueCache = undefined ;(column as any)._resolvedAggregationFnsCache = undefined }, - - initRowInstanceData: (row) => { - ;(row as any)._aggregationValuesCache = makeObjectMap() - }, } diff --git a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts index 5a72bfe673..8c6a391924 100644 --- a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts +++ b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts @@ -302,8 +302,8 @@ export interface Cell_Aggregation { /** Internal per-row cache used while grouped aggregates are evaluated. */ export interface Row_Aggregation { - /** Cached aggregate results keyed by column id. */ - _aggregationValuesCache: Record + /** Cached aggregate results keyed by column id; created lazily on grouped rows. */ + _aggregationValuesCache?: Record } /** Values passed to a column-level aggregation-value provider. */ diff --git a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts index 67e7b69614..9c28ed6d91 100644 --- a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts +++ b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.utils.ts @@ -95,6 +95,52 @@ export function normalizeAggregationRows< return result } +/** + * Frontier selection for rows that are distinct nodes of a single row tree — + * the row models the table builds itself. Skips `normalizeAggregationRows`' + * duplicate-id guard (disjoint subtrees cannot revisit a row) and returns + * `rows` unchanged when no row descends, so the default `maxDepth: 0` case + * costs nothing per aggregation. + */ +export function normalizeUniqueAggregationRows< + TFeatures extends TableFeatures, + TData extends RowData, +>( + rows: ReadonlyArray>, + maxDepth = 0, +): ReadonlyArray> { + const normalizedMaxDepth = resolveMaxAggregationDepth(maxDepth) + + let needsDescent = false + if (normalizedMaxDepth > 0) { + for (let i = 0; i < rows.length; i++) { + if (rows[i]!.subRows.length) { + needsDescent = true + break + } + } + } + if (!needsDescent) return rows + + const result: Array> = [] + + const visit = (row: Row, depth: number) => { + if (row.subRows.length && depth < normalizedMaxDepth) { + for (let i = 0; i < row.subRows.length; i++) { + visit(row.subRows[i]!, depth + 1) + } + return + } + result.push(row) + } + + for (let i = 0; i < rows.length; i++) { + visit(rows[i]!, 0) + } + + return result +} + function getAutoAggregationFnName( value: unknown, ): 'extent' | 'sum' | undefined { @@ -267,13 +313,21 @@ export function aggregateColumnValue< column: Column groupingRow?: Row rows: ReadonlyArray> + /** + * Marks `rows` as distinct nodes of a single row tree (rows the table's own + * row models produced), enabling frontier selection without the + * duplicate-id guard. Caller-supplied row arrays must omit this. + */ + uniqueRows?: boolean }): unknown { - const { subRows, column, groupingRow, rows } = args + const { subRows, column, groupingRow, rows, uniqueRows } = args const internalColumn = column as Column_Internal const maxDepth = resolveMaxAggregationDepth( args.maxDepth ?? internalColumn.columnDef.maxAggregationDepth, ) - const aggregationRows = normalizeAggregationRows(rows, maxDepth) + const aggregationRows = uniqueRows + ? normalizeUniqueAggregationRows(rows, maxDepth) + : normalizeAggregationRows(rows, maxDepth) const entries = column_getAggregationFns(internalColumn) const isMultiple = Array.isArray(internalColumn.columnDef.aggregationFn) const canMerge = @@ -284,6 +338,8 @@ export function aggregateColumnValue< (row as any).groupingColumnId !== column.id, ) + const getValue = (row: Row) => row.getValue(column.id) + const execute = (entry: ResolvedAggregationFn) => { const definition = entry.aggregationFn if (!definition) return undefined @@ -292,7 +348,7 @@ export function aggregateColumnValue< ...(subRows ? { subRows } : {}), column, columnId: column.id, - getValue: (row: Row) => row.getValue(column.id), + getValue, ...(groupingRow ? { groupingRow } : {}), maxDepth, rows: aggregationRows, @@ -378,6 +434,7 @@ export function column_getAggregationValue< column: column as any, maxDepth: resolvedMaxDepth, rows: model.rows, + uniqueRows: true, }) ;(column as any)._aggregationValueCache = { aggregationFnOption, diff --git a/perf-agg.md b/perf-agg.md new file mode 100644 index 0000000000..3fc657923a --- /dev/null +++ b/perf-agg.md @@ -0,0 +1,218 @@ +# `@tanstack/table-core` — Aggregation Refactor Performance Deep Dive + +2026-07-16. Follow-up to `perf-done.md`. Investigates the grouping/aggregation +regression introduced by the `rowAggregationFeature` refactor +(beta.46 → beta.51) and documents the fixes that landed from this deep dive. + +## Symptom + +The row-model benchmark (`benchmark-examples`, single-level `grouping: ['group']`, +one aggregated column, 20 groups) regressed on every grouping scenario: + +- vs **beta.46** (pre-refactor): all 27 meaningful grouping measurements regressed + > 5%; median raw regression **81%** (browser report + > `row-model-2026-07-16T12-54-16.147Z.html`). +- vs **v8**: every 400k aggregation scenario flipped from faster to **6–30% slower**. +- Everything outside grouping was roughly unchanged, so the cause was isolated + to the new aggregation execution path. + +## Reproduction harness + +Browser runs are the ground truth but slow to iterate. Three Node scripts were +added under `benchmark-examples/scripts/` that import a repo's built +`table-core` dist directly and replicate the benchmark stage (grouped model +build + one aggregated `getValue` per group row): + +```bash +# per-scenario timings (fresh process per scenario; each sample builds a fresh table) +SCENARIO=sum node --expose-gc --max-old-space-size=10240 scripts/agg-diagnose.mjs ../table 400000 5 + +# 2-level grouping value-checksum + timing comparison (exercises the merge path) +node --expose-gc scripts/agg-multilevel-check.mjs ../table 100000 + +# per-table heap retention +node --expose-gc scripts/agg-leak-check.mjs ../table 200000 +``` + +The harness reproduced the browser regression at matching magnitude +(+52%…+92% per scenario at 400k) with identical aggregate values, and a +`--cpu-prof` profile gave exact attribution. + +Caveat: dist builds keep `process.env.NODE_ENV` checks that app bundlers +strip in production, and env reads are expensive in Node, so +`table_getColumn`-adjacent costs are somewhat overstated relative to a +production browser bundle. All comparisons here are same-harness, so relative +deltas hold. + +## Root causes (profile-confirmed) + +CPU profile of beta.51 `sum` @ 400k (whole process, 5 samples + 2 warmups): +`visit` (row normalization) **23.8%** self, GC **18.9%**, `table_getColumn` +**12.3%**, memo machinery ~8%. + +### R1. Per-column frontier normalization re-walks every row through a `Set` + +`aggregateColumnValue` called `normalizeAggregationRows(rows, maxDepth)` on +every aggregated `getValue` of every group row. With the default +`maxAggregationDepth: 0` the "frontier" is just the group's own rows — but the +function still allocated a result array plus a `Set`, and paid +`seen.has(row.id)`/`seen.add(row.id)` string hashing for **every row in every +group** (O(total rows) per aggregated column). beta.46 iterated the group's +rows directly with zero preprocessing. + +### R2. `leafRows` construction pays the same normalization again + +`createGroupedRowModel` computed +`leafRows = normalizeAggregationRows(groupedRows, Infinity)` for every group — +a second O(total rows) `Set` pass per model build. beta.46 aliased +`leafRows = groupedRows` at depth 0 (free) and only flattened for nested +levels. + +R1 + R2 explain the near-constant ~230–245ms added at 400k across _all_ +scenarios — most visible for `count` (+153% in the browser), whose actual +aggregation work is `rows.length`, i.e. O(1). + +### R3. `min`/`max`/`extent` collect-then-compare with `instanceof`-heavy comparator + +The rewritten range fns first materialized an O(n) values array +(`collectRangeValues`), then reduced it with `compareRangeValues`, which +performs two `instanceof Date` checks per comparison. beta.46 did one +typeof-guarded pass with no intermediate array. This is the extra ~140ms that +made `min` the worst scenario (browser: +136% vs beta.46, +29% vs v8). + +### R4. Eager `_aggregationValuesCache` object on every data row + +`rowAggregationFeature.initRowInstanceData` allocated a `makeObjectMap()` on +**every row** at core-row-model build, though only synthetic group rows ever +store aggregates — and the grouped model already creates the cache lazily with +`??=`. At 200k rows this added ~40MB retained heap per table (measured +193MB/table vs 152MB on beta.46) and extra GC scanning during the measured +stage. + +### R5 (pre-existing, both builds). Per-row memoized `table.getColumn` in `groupBy` + +`groupBy` resolved the grouping column through +`row_getGroupingValue → row.table.getColumn(columnId)` for every row — a +memoized-API dispatch (deps-array allocation + compare) per row, ~12% of the +profile. Not part of the regression, but fixing it is what pushes v9 past its +old baseline. + +## Fixes implemented + +All in `packages/table-core`; behavior gates: full unit/implementation suite +(1000 tests), `test:types` (incl. declaration emit), size-limit +(19.27 kB / 20 kB) all pass. + +1. **`normalizeUniqueAggregationRows`** + (`rowAggregationFeature.utils.ts`) — frontier selection for rows that are + distinct nodes of a single row tree (everything the table's own row models + produce). Skips the duplicate-id `Set` (disjoint subtrees can't revisit a + row) and returns the input array unchanged when nothing descends, so the + default `maxDepth: 0` case is a zero-cost alias. The public + `normalizeAggregationRows` keeps its dedupe semantics for caller-supplied + row arrays. + +2. **`aggregateColumnValue({ uniqueRows })`** — the grouped row model and the + default-model path of `column_getAggregationValue` pass `uniqueRows: true` + and route through the fast variant; explicit `getAggregationValue({ rows })` + calls still dedupe. Also hoisted the shared `getValue` closure out of the + per-entry `execute`. + +3. **`createGroupedRowModel`** — `leafRows` now uses + `normalizeUniqueAggregationRows(groupedRows, Infinity)` (identity for flat + groups, `Set`-free flatten for nested ones), and `groupBy` resolves the + grouping column **once per level** instead of per row, mirroring + `row_getGroupingValue`'s caching contract branch-for-branch. + +4. **Single-pass `min`/`max`/`extent`** (`aggregationFns.ts`) — no intermediate + values array; the running best is tracked alongside its numeric + representation so `Date` columns compare by cached `getTime()` and numeric + columns never see `instanceof`. NaN seeding and first-valid-kind selection + preserved exactly. Other fns hoist `context.rows` out of loop headers. + +5. **Removed `initRowInstanceData`** from `rowAggregationFeature`; + `Row_Aggregation._aggregationValuesCache` is now optional and created + lazily on grouped rows only. + +### Behavioral notes + +- Rows fed by the table's own grouped model no longer collapse duplicate row + IDs (only possible with a colliding `getRowId`) during aggregation — this + matches v8 and beta.46. Caller-supplied rows keep the documented dedupe. +- For an all-flat group, `row.leafRows` is the internal partition array itself + (same aliasing beta.46 had at depth 0). +- Aggregate results are unchanged: single-level checksums and 2-level + merge-path checksums are identical to beta.46 across all scenarios. + +## Results (Node harness medians) + +### Single level, 400k rows, 20 groups + +| Scenario | beta.46 | beta.51 | fixed | vs beta.51 | vs beta.46 | +| ----------- | ------: | ------: | ----: | ---------: | ---------: | +| sum | 552.9 | 893.0 | 388.9 | −56% | **−30%** | +| min | 578.2 | 974.1 | 412.9 | −58% | **−29%** | +| max | 532.5 | 1021.8 | 409.4 | −60% | **−23%** | +| extent | 598.0 | 916.6 | 417.6 | −54% | **−30%** | +| mean | 511.4 | 918.8 | 388.4 | −58% | **−24%** | +| median | 597.0 | 1015.3 | 486.0 | −52% | **−19%** | +| unique | 519.6 | 791.4 | 387.3 | −51% | **−25%** | +| uniqueCount | 518.1 | 828.8 | 372.3 | −55% | **−28%** | +| count | 310.5 | 565.6 | 200.8 | −64% | **−35%** | + +### Other sizes (fixed vs beta.46) + +| Scenario | 20k | 100k | +| -------- | ------------------: | ------------------: | +| sum | 11.8 vs 14.0 (−16%) | 83.8 vs 96.0 (−13%) | +| min | 11.4 vs 14.3 (−20%) | 90.6 vs 96.5 (−6%) | +| count | 6.6 vs 8.8 (−25%) | 40.4 vs 52.1 (−22%) | + +### Two-level grouping (merge path), 100k rows — not covered by the browser suite + +| Scenario | beta.46 | fixed | Δ | +| ----------- | ------: | ----: | ---: | +| sum | 168.1 | 102.5 | −39% | +| min | 180.2 | 105.6 | −41% | +| max | 171.4 | 121.7 | −29% | +| extent | 210.8 | 120.9 | −43% | +| mean | 205.9 | 131.4 | −36% | +| count | 140.4 | 75.4 | −46% | +| uniqueCount | 168.0 | 122.3 | −27% | + +Value checksums identical to beta.46 in every row above. + +### Memory + +Retained heap per constructed 200k-row table: beta.51 ~193MB → fixed ~152MB +(= beta.46). Post-fix profile: `visit` no longer appears; remaining top costs +are GC (core-model construction churn), `row_getValue` cache-miss column +resolution, and the real aggregation loops. + +The browser benchmark should be re-run to confirm, but given the Node harness +tracked the browser numbers closely on the way down, the expectation is v9 +returns to a comfortable lead over v8 at 400k (beta.51 was 6–30% behind; the +fixed build is 52–64% faster than beta.51 on these scenarios). + +## Remaining opportunities (not done here) + +1. **Per-miss `table.getColumn` dispatch in `row_getValue` / `row_getUniqueValues`** + (`coreRowsFeature.utils.ts`) — each first read of a (row, column) pays a + memoized-API dispatch (~12–16% of the profiled run even after the groupBy + hoist, though partly a Node/`process.env` artifact). A cheaper internal + column-record lookup would benefit every row model, not just grouping. +2. **Table instances are never released** — sequentially constructing tables + retains ~150MB per 200k-row table after full GC on _both_ beta.46 and + beta.51 (pre-existing; likely reactivity subscriptions rooting the table). + Long-lived apps that rebuild tables (or state resets that reconstruct) + would accumulate. Deserves its own investigation. +3. **Eager `_groupingValuesCache` per row** (`columnGroupingFeature.ts:83`) — + same shape as R4, pre-existing on both builds; only rows grouped by a + column with `getGroupingValue` ever use it. Removing the eager init + requires keeping `row_getGroupingValue`'s write-guard semantics in mind. +4. **Benchmark gap:** the browser suite has no multi-level grouping scenario, + so the merge path (`definition.merge` + `subRowResults`) is untested there. + `agg-multilevel-check.mjs` covers it in Node; a two- or three-level browser + scenario would make regressions in that path visible in the standard report. +5. `median` remains the slowest scenario (sort-based); a selection algorithm + (quickselect) would cut it further if it ever matters.