Skip to content

perf(render): rewrite VirtualDomVertical windowing on a Fenwick coordinate model - #4937

Closed
lukecotter wants to merge 6 commits into
tabulator-tables:masterfrom
lukecotter:perf/render-window-rewrite
Closed

perf(render): rewrite VirtualDomVertical windowing on a Fenwick coordinate model#4937
lukecotter wants to merge 6 commits into
tabulator-tables:masterfrom
lukecotter:perf/render-window-rewrite

Conversation

@lukecotter

@lukecotter lukecotter commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Draft: the numbers below are from a headless harness, and a manual real-browser pass is
still outstanding. Opening it now so the approach can be discussed before that.

What this does

Rewrites VirtualDomVertical's windowing on a Fenwick (binary indexed) coordinate model
with diff-based reconciliation: a scroll detaches only the rows that actually leave the
window and attaches only those that enter, instead of walking rows off one end and
rebuilding.

The previous implementation is kept verbatim as VirtualDomVerticalLegacy.js behind
a temporary renderVerticalLegacy option, so this is revertible per-table at runtime.
That option and that file are meant to come out after a short beta, not to stay.

Numbers

500k rows, headless Chromium, K=5, medians. custom is a downstream fork of this
renderer that motivated the work — shown as the target, not as something to merge.

Metric master this PR custom (target)
initial render (ms) 97.6 97.5 97.6
initial render, variable heights (ms) 105.6 109.1 102.1
steady churn, uniform 8982 5361 1794
steady churn, variable 2715 1487 466
fling churn, uniform 14205 6474 0
fling churn, variable 3935 2721 0
fling p95, uniform (ms) 4.1 2.4 0.1
fling p95, variable (ms) 2.8 1.3 0.1
steady p95, uniform (ms) 0.9 1.1 0.1
steady p95, variable (ms) 0.8 1.0 0.1

Live-tree mutation churn falls 40% at steady scroll speed and 55% on a fling, and fling
p95 halves on both height profiles.

The steady p95 deltas of 0.1–0.2 ms are inside the harness's resolution and should
not be read as a regression. Relative standard deviation on that metric runs 9–29% at
sub-millisecond frame times. An earlier "1.8 ms steady p95 regression" from this same
harness did not reproduce under a same-session interleaved A/B and was withdrawn.

Re-render and sort:

Scenario / metric master this PR custom
rerenderInPlace nodesFilter 60 47 24
rerenderInPlace nodesClear 80 25 25
rerenderInPlace nodesReplace 160 31 31
rerenderInPlace msReplace 115.9 110.1 118.5
sortChurn nodesDesc / nodesAsc 120 / 120 65 / 58 58 / 58
sortChurn msDesc / msAsc 629 / 581 616 / 561 633 / 571
rerenderTree nodesCollapse / nodesExpand 120 / 120 65 / 58 58 / 58
rerenderTree msCollapse / msExpand 31.4 / 30.3 32.9 / 28.7 56.9 / 48.8

nodesReplace 160 → 31 and nodesClear 80 → 25 come from a durable height cache:
rerenderRows reuses cached row heights instead of remeasuring the window, so
replaceData with the same shape stops being a full rebuild. On tree toggles this is
the fastest of the three.

nodes is row attach/detach counted synchronously on .tabulator-table, so unlike
MutationObserver churn it is comparable across renderers that build in different
places.

One figure that moves the wrong way

At 1px scroll steps, render batches go 24 → 48. The diff path renders on every
scroll event where the old window check renders on every other one. Total row work is
identical at 48 nodes and live churn is lower at 144 vs 240, so each render is cheaper —
the extra batches are the diff path catching a one-row change that the coarser check
misses. Stating it rather than hiding it behind the churn win.

A correctness point from the same scenario: a scroll that stays inside the overscan does
0 nodes / 0 churn / 0 batches on both master and this PR, and 2/2/2 on the downstream
fork.

Height-index lookup

_findRowAt binary-searched over cumulative prefix sums — roughly 19 probes times 19
tree levels, twice per scroll tick. It is now a single descent over the two combined
Fenwick trees, valid because a linear combination of two Fenwick trees over one index
space is itself a Fenwick tree.

Metric Before After
prefix-sum calls per tick 45.9 12
lookup (ms) 0.031 0.006

Gated by 104 differential tests in
test/unit/core/rendering/VirtualDomVerticalIndex.spec.js, keeping the old binary
search as the reference oracle over 6 height distributions and 17 sizes straddling
powers of two. The descent sums nodes in the opposite order to the prefix sum, so in the
last bits of a float it can land one row either side of a boundary — measured at 2.6% of
3.69M boundary-heavy probes. _findRowAt reconciles against the prefix sum for that
reason; those loops are load-bearing, not defensive padding.

Open items, disclosed

  • Overscan is larger than the fork's: 40 rows in the DOM against 23. Deliberate.
    A scrollbar drag scrolls on the compositor thread, so a small overscan lets the
    compositor out-run main-thread rendering and briefly expose blank padding. Measured
    mid-list headroom is 403px above / 1411px below on master and 211/197 here; shrinking
    it further trades that blank band for churn.
  • Two e2e tests measured the wrong thing, and both are now fixed without relaxing a
    tolerance.
    They previously read 36px and 57px against a 20px tolerance on this branch.
    • The two scroll-jump cases computed the jump as moved − scrolled, where scrolled
      is the holder's own scrollTop delta. A virtual renderer revises its total
      scrollHeight as it measures real row heights, and scrollTop is read from the top of
      that changing document, so it shifts by the height correction while nothing on screen
      moves. In every sample the reported jump equalled the scrollHeight change exactly,
      while the tracked row moved the full requested distance on every step. The fix compares
      screen movement against the requested distance and lives in fix(render): disable browser scroll anchoring on the table holder #4935, because with an
      honest metric the old measurement also fails on current master, at 124px.
    • frozen-rows used the last rendered row as a sanity check and required it to still be
      in the DOM after a 500px scroll, which asserts a buffer size rather than frozen-row
      behaviour. It now checks that the rendered window moved on. After the scroll this
      branch renders 20 rows and the frozen row is still pinned.
      Both tests still fail on the code they were written to catch: scroll-jump at 280px
      before the Tabulator vertical scroll skipping / jumping #3654 fix, frozen-rows at its layout assertion before the Freezing a row no longer works; #4871 fix. The
      20px and 5px tolerances are untouched. The suite is 22/22 e2e and 576/576 unit here.
  • Fling deferral is held back to a separate PR behind its own option, because it
    turns a synchronous path asynchronous and so changes a behavioural contract.
  • overflow-anchor: none on the table holder is load-bearing here; that fix is included
    as the first two commits and also proposed on its own.

Depends on

The overflow-anchor fix, included here as the first two commits. Merge that PR first;
this one then reduces to four commits.

Chrome's scroll anchoring adjusts scrollTop when the virtual renderer inserts rows above the viewport, double-compensating against the renderer's own paddingTop/scrollTop management and causing drift on scroll-up. Set overflow-anchor:none on .tabulator-tableholder, the standard approach for JS-managed virtual scrollers.
e2e guard that .tabulator-tableholder computes overflow-anchor:none, so the
scroll-anchoring fix can't silently regress out of the built CSS.
…nate model

INCOMPLETE — do not merge. Replaces the incremental padding/tracker model with:
- dual Fenwick trees for O(log n) cumHeight / O(log^2 n) findRowAt
- padding recomputed from the index each render (never incrementally clamped)
- diff-based windowing (detach leavers / attach enterers only)
- row-count overscan, batched attach phases, durable data-keyed height cache
- previous implementation retained as VirtualDomVerticalLegacy behind the
  renderVerticalLegacy option

Status: jsdom suite green (45/469); 17/21 e2e green; rendered window down to
~12 rows vs 40 (the churn target). Outstanding: variable-height scroll-up
content shift (scroll-jump canaries report 328-781px) and frozen-rows layout.
Measurement guard now matches the legacy one (!heightInitialized ||
!getHeight()) — without it rows measured while detached stayed permanently
unmeasured and the coordinate space never left the estimate.
Three defects found by tracing the render path frame by frame:

1. Insertion routing. _diffRender advanced renderedRange BEFORE calling
   _attachRanges, but _attachRanges decides prepend-vs-append by comparing the
   incoming range against the range still rendered. With it already advanced the
   prepend test could never be true, so rows extending the window upward were
   appended BELOW it and the DOM fell out of index order. This was the dominant
   bug: scroll-up jump 604px -> 36px.

2. Measurement guard. Rows were gated on !heightInitialized alone, but a row
   first measured while detached caches a zero outerHeight and is still flagged
   initialized, so it stayed permanently unmeasured (measuredCount stuck at 18
   of 2000) and the coordinate space never left the estimate. Now matches the
   legacy guard: !heightInitialized || !getHeight().

3. Height fed to the index was read before setCellHeight() normalized the cell
   heights, so the index disagreed with the DOM. Re-read after normalization.

Also merges the overflow-anchor fix, without which the browser's own scroll
anchoring fought the padding writes and pinned scrollTop at the bottom.

500k bench vs stock (custom renderer target in brackets):
  steady uniform churn   8982 -> 5361  [1794]
  steady variable churn  2715 -> 1487  [466]
  fling uniform churn   14205 -> 6474  [0]
  fling uniform p95      4.10 -> 2.50  [0.10]
  initial render           98 -> 100   [98]

Remaining: scroll-jump canaries report 36px/57px against a 20px tolerance, and
frozen-rows tracks a row across a 500px scroll that no longer stays in the
smaller window (test encodes the old 3-viewport buffer; rows and the frozen row
verified correct after the scroll).
…fset

_findRowAt binary-searched over _cumHeight — about 19 probes, each walking
about 19 tree levels, twice per scroll frame. A linear combination of two
Fenwick trees over the same index space is itself a valid Fenwick tree, so
the combined node value can be formed per node and the tree descended
directly in O(log n).

Measured at 500k rows: _cumHeight calls per scroll tick 45.9 -> 12, time
inside _findRowAt 0.031 -> 0.006 ms.

The descent accumulates nodes in ascending index order whereas prefixSum
walks descending, so the two sums diverge in the last bits of a float at or
near a row boundary — measured at 2.6% of 3.69M boundary-heavy probes.
_findRowAt reconciles against _cumHeight, so the answer is identical to the
binary search it replaces.

The new spec keeps that binary search as a reference oracle, over 6 height
distributions and 17 sizes straddling powers of two.
With renderHorizontal:"virtual", a row that leaves the vertical window is
detached but not deinitialized, so it keeps `initialized === true` and the
cell set it held when it left. VirtualDomHorizontal's addColRight/addColLeft
update row.modules.vdomHoz only for the rows visible at that moment, so a
row re-entering the window after a horizontal scroll carried a stale column
window: wrong columns, misaligned with the header.

Row.initialize's else-branch is the resync hook — it falls through to
rerenderRowCells, whose leftCol/rightCol guard rebuilds the cells only when
the column window really moved. The diff path called initialize only for
rows it had never initialized, so that branch was never reached. The legacy
renderer called it unconditionally on its incremental scroll paths.

Deliberately no second argument: VirtualDomHorizontal.rerenderRowCells reads
it as `force`, and a forced rebuild of every re-attached row on every scroll
tick is the cost the diff path exists to avoid.

Row.create is a guarded no-op and the base rerenderRowCells is empty, so with
the default horizontal renderer this costs nothing: at 500k rows steady-scroll
churn stays 5361, nodes 1789, p95 0.9 ms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant