Skip to content

S2 sort at target scale (50k rows) never settles: 515ms of transition work against a 400ms window #457

Description

@blove

Found while researching #452's scaling behaviour. No interaction had ever been measured at target scale. Doing so:

S2 sort, 50,000 rows pretable tanstack
status partial ×3 — "the surface never held still for 2 frames within 48" completed ×3
latency unreportable ~33ms
settle >400ms, never ~100ms

Decomposition

The row model is DOM-free, so the transition can be timed directly in Node (createLocalRowModel + S2 dataset, sort one column, wait for ready), sweeping transitionBudgetMs:

S2 hypothesis (3,000 rows)              S2 target (50,000 rows)
budget 0.25ms -> 41.3ms  (101 slices)   budget 0.25ms -> 589.9ms  (1834 slices)
budget 1ms    -> 26.4ms                 budget 1ms    -> 585.2ms
budget 4ms    -> 21.3ms                 budget 4ms    -> 602.7ms
budget 1000ms -> 22.2ms  (11 slices)    budget 1000ms -> 515.4ms  (195 slices)

Two different problems by scale:

  1. At 3k, the yield tax is ~46% of wall time. DEFAULT_BUDGET_MS = 0.25 (cooperative-transition.ts:75) buys a quarter-millisecond of work per scheduler hop, and the hop costs about as much. Raising the budget to ~4ms nearly halves the sort. This alone closes most of pretable's interaction latency is ~2x TanStack's on S2 sort and filter #452's measured 2x at hypothesis scale.
  2. At 50k, the work itself is ~515ms — ~10µs per row. Per insertRecord (cooperative-transition.ts:556-588): a full queryPlan.evaluate per row (re-runs every active column accessor and re-evaluates ALL filters even when only the sort changed, against a fresh per-plan cache — compiled-query.ts:1298, 1373-1465), a record freeze, a persistent HAMT insert, and an O(log n) AVL insert. TanStack sorts the same 50,000 rows in ~33ms total: rows.slice().sort(compareRows) over cached values (createSortedRowModel.js:81-83, verified synchronous with no chunking anywhere in the installed package).
  3. Note maxUnitsPerSlice = 256 caps slices even at effectively-synchronous budgets (195 hops at 50k regardless), so budget tuning alone cannot fix target scale.

Levers, in order of measured ceiling

  • Budget/duty-cycle: 0.25ms → ~4ms adaptive. Measured: 41.3 → 21.3ms at 3k. Cheap, low-risk, immediately closes most of the hypothesis-scale gap.
  • Sort-only metadata reuse: a sort change rebuilds every row's metadata from a cold cache. When filters/groups are unchanged, prior evaluation results (or at least filter verdicts and unchanged sortKeys) should carry over. Bounded by the evaluate share of the 10µs/row.
  • Bulk rebuild path: sorting 50k precomputed sortKeys with Array.sort and bulk-building the tree bottom-up is O(n log n) with array constants; 50k incremental persistent inserts is the same complexity with tree constants. The persistent structures earn their keep on INCREMENTAL change; a full re-sort is exactly the case where they do not.
  • Progress publishes: the transition publishes nothing until complete (cooperative-transition.ts:328-331 by design), so at target scale the user sees a frozen sort for half a second. Worth a design discussion — partial sorts are arguably worse than a spinner, but "nothing for 590ms" is worst.

Relation to #452

#452's 2x-at-3k is roughly half yield tax (fixable by budget) and half per-row work (levers 2-3). This issue is the same root causes at a scale where they breach the harness window entirely. Fixing the budget alone would NOT fix this — the 50k wall time barely moves across budgets.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions