Source: context/test-evidence/scale-bisect.md

On this page

Scale bisect: how big a document does improvise handle today (improvise-felo.10)

Measured on gh/main @ 8220a065, Apple M4 Max, 64 GB (sysctl: hw.memsize 68719476736), macOS, release build (lto, opt-level 3), native only (no wasm, no real OPFS, no terminal). Harness: benches/scale_bisect.rs (one configuration per process) driven by bench/scale_bisect.py, bench/sweep.py, bench/edge.py, bench/full.py. Reproduce: nix develop --command cargo build --release --bench scale_bisect, copy the binary from target/release/deps/scale_bisect-* to /tmp/sb, then python3 bench/edge.py.

Budgets (user ruling 2026-10-03): 8 ms is the headline (120 FPS, hot path or defer to background); 16 ms, 100 ms (noticeable) and 1 s (broken) are reference crossings.

#Method, and what is real

Every number goes through the production path: App::new (open: formula recompute + GridLayout::with_frozen_records), App::dispatch_key(Down) (keypress/scroll: it calls rebuild_layout, then cmd_context, which runs compute_col_widths over the whole grid, then applies effects), project::grid_of (TUI frame: whole grid; browser: a 45x12 window), App::apply_effects with effect::SetCell, SetAxis/HideItem/ToggleGroup/TogglePruneEmpty (reshape), Host::connect and Host::handle with a caught_up ModelCommand::SetCell (browser path incl. projection), format_md/parse_md/save_to(MemoryFs). Document: C0..C{cats-1} with K items, N distinct random tuples ("records", capped at K^cats/2), two measures each (2N cells), _Measure on Column. Timings are the median of up to 3-8 in-process repeats; boundary rows are the median of 3 separate processes run sequentially (spread shown). Earlier exploratory sweeps ran 2-3 processes in parallel and agree with the sequential runs within ~5%.

View::prune_empty defaults to false (view/types.rs), so "prune off" is what a user gets until they toggle it; "prune on" is reported next to it. Rows below are the layout's row_count.

Dominant cost (reasoned, not profiled): the release profile strips symbols, so sample could not symbolicate. The reason is in the code and the timings agree with it: GridLayout::cell_key/row_item do data_items(&self.row_items).nth(row) (view/layout.rs ~253/292/807/870), O(row) per lookup, and compute_col_widths (every keypress, via cmd_context) and grid_of visit every cell, so both are O(rows^2). Evidence: with prune off, rows 729 -> 5.4 ms, 4096 -> 74.6 ms, 19683 -> 1248 ms (x6.7 rows -> x14 time -> x17 time). A 45x12 browser window costs the same as the whole-grid TUI frame (K=30: 2274 vs 2298 ms) because the widths pass is whole-grid whatever the viewport says, so viewport narrowing buys nothing today. GridLayout build itself is cheap (~90 ns/row, ADR 008).

#Attribution: state+projection vs render/serialize (K=10, 1000 rows, prune off; 3 runs)

stepms
keypress (state: rebuild + widths + effect)9.5-9.7
grid_of whole grid (TUI projection)11.7-12.0
GridWidget render into a 200x50 ratatui Buffer0.12
grid_of 45x12 window (browser projection)9.1-9.3
JSON encode of one SetCell's projected updates0.001-0.005 (0.6-1.1 KB)
JSON encode of the connect Snapshot0.02 (21 KB)

Render and serialize are <=0.5 ms in every row measured (GridWidget 0.08-0.53 ms up to 14,500 records; snapshot encode <=0.07 ms; records-mode SetCell updates 21 KB in 0.02 ms). The 8 ms is entirely state and projection. Not measured: the rest of draw() (panels, status; draw is private to the binary), terminal I/O, browser JSON decode and DOM paint.

#Axis B: items per category K, 3 categories on Row, N = 100k (capped at K^3/2), prune OFF

Krowsopenkeypressbrowser windowSetCellmaxrss MB
51250.10.50.50.28
97290.25.4 (5.3-5.5)5.00.913
101,0000.29.6 (9.5-9.7)9.21.214
111,3310.312.712.71.610
121,7280.320.019.32.111
153,3750.565644.014
164,0960.674.6 (74.3-75.2)73.4--
174,9130.7105 (105.0-105.5)103--
208,0001.13023019.421
2515,6251.977377118.833
2617,5762.2967-977962--
2719,6832.41,248 (1,244-1,251)1,244--
3027,0003.12,2622,2743149
4064,0006.710,85810,83373235
60216,00021.6timeout (>60 s)---
100010^9refused (see below)

Hot-path keypress/scroll and browser window: largest inside 8 ms: K=9 (729 rows); inside 16 ms: K=11; first over 100 ms: K=17 (4,913 rows); first over 1 s: K=27 (19,683 rows). SetCell alone (no widths pass; layout rebuild only, ~linear in rows): inside 8 ms to K~19 (K=15 4.0, K=20 9.4), 16 ms ~K=24, 100 ms ~K=45 (K=40 = 73 ms), 1 s extrapolated ~K=95 (extrapolated, not measured). Open stays <25 ms to K=60.

3 x 1,000 on Row cannot be opened at any N: the layout materializes the cross product (10^9 rows, ~150 B each = ~150 GB, ADR 008). The harness refuses above 30M rows (rows_cap) rather than swap the machine, so the 10^9 number is the ADR's estimate, not a measurement here.

#Prune ON (same documents)

Krowsopenkeypressbrowser windowSetCell
71712.04.5 (4.4-4.6)1.15.4
82563.07.0 (7.0-7.1)1.68.5
93645.312.82.715.3
1166512.229.66.2~28
131,09829.07114.5~70
141,3724811924-
204,000300747145903
214,630401968-992190-
257,8127671,8993662,328
3013,5002,2555,5551,0476,804

Prune on: keypress inside 8 ms to K=8, first over 100 ms K=14, ~1 s at K~21-22 (K=22 measured 0.93-0.95 s, a different N cap; first clearly over: K=25). Pruning shrinks the grid, so the browser window is cheaper than prune off at the same K (K=20: 145 ms against 302 ms), but every keypress re-runs the prune (dispatch_key calls rebuild_layout), so keypress and SetCell are worse with prune on (K=10: 23 vs 9.9 ms). Prune is also quadratic (nth). Conclusion: prune on/off moves the boundary by about one K at 8 ms and makes 100 ms/1 s earlier (K=14/21 vs 17/27); it never rescues the 3x1000 case.

#Axis A: records N at fixed K (K=30, 27,000 rows)

Nprune off keypressprune on rowsprune on keypressprune on window
1004511008610.3
1,0005271,0001,0596.4
5,0001,0135,0002,178140
13,500 (cap)2,26713,5005,5551,047

Prune off: ~450 ms of widths over 27k empty rows + ~1.3 us per stored cell; N never gets the keypress under 100 ms at K=30, and at K=1000 N is irrelevant because the layout cannot be built (rows = K^3). The N-axis has no bisect point in the 3x1000 regime; N only matters once K is small enough that rows fit, and there it is a second-order term. Open is flat (3.1-3.6 ms) in N with prune off.

#Axis C: categories on Row (prune off)

Kcatsrowsopenkeypressnote
1021000.10.2
1031,0000.29.9over 8 ms
10410,0001.4880~1 s
105100,00014timeout >100 s
367290.327.95x worse than K=9 x 3 cats at the same rows
386,5612.02,798
310-1259k-531k18-210timeoutopen alone passes 100 ms at 11 cats
2101,0240.6172
2124,0962.23,190
21416,384963,100

The cap of 24 is unreachable: rows = product of items, so a view with c Row categories of K items dies by K^c ~ 10^4 rows (1 s) regardless of N, and with two items per category by 12-14 categories. Per-row cost also grows with category count (each row_item is a Vec of c items). Not measured: Row categories with prune on, other axes absorbing categories (the realistic mitigation: only 1-2 on Row).

#Axis D: records mode over N records (with_frozen_records, the improvise-sk32 quadratic)

Nopenkeypressbrowser windowSetCell (apply)host SetCell+projectmaxrss MB
3001.50.70.11.6-10
1,0002.95.6 (5.5-5.7)0.310-12
1,2003.97.9 (7.7-8.0)0.3---
1,3004.59.30.315.51726
1,8008.416.50.4---
3,00022.9470.774-20
4,400-4,60047-5195-1020.914915855
10,0002484921.8734-46
14,000-14,500472-500940-1,0022.6-2.81,4351,459135-148
30,0002,1984,3145.66,467-125
100,00025,42051,23419.3not reached (killed at 100 s)-not reached

Records mode, keypress/scroll: inside 8 ms to N~1,200; 16 ms N~1,800; 100 ms N~4,500 (first over: 4,600 = 102 ms); 1 s N~14,500. Open crosses 8 ms ~1,750, 16 ms ~2,300, 100 ms ~6,500, 1 s ~20,000. Time ~ N^1.95. SetCell is ~1.5x keypress (it rebuilds and re-groups). The browser window is cheap (<20 ms even at 100k) because records mode has two columns and the width walk is short, so the 51 s is rebuild_layout, not projection. Prune on/off does not change records mode (prune applies to pivoted axes; the sweep ran off). Records per KB: ~4 KB/record process-wide at 14k (maxrss includes the document generator, so read as an upper bound); the 100k memory was not obtained.

#Axis E: save / load and per-command persistence

Mode none (all categories off-axis, so layout is trivial and only the store scales), 3 x 1,000, N records = 2N cells:

cellsformat_mdtext MBparse_mdpersist per accepted command (save_to MemoryFs)maxrss MB
2,00013.80.14.512.418
20,0001560.863716180
60,0005272.5119539236
200,000 (the target)1,9748.44011,984663
600,0006,470251,3206,4601,894
2,000,00023,120844,66823,0885,611

Linear: ~10 us/cell to format, ~2 us/cell to parse. Crossings, format_md: 8 ms ~700 cells, 16 ms ~1.5k, 100 ms ~11k, 1 s ~100k cells. parse_md: 8 ms ~4k cells, 100 ms ~50k, 1 s ~450k. At the target (200k cells) every accepted command in the browser worker costs ~2 s of format alone before OPFS write time (not measured: MemoryFs only). Persist per command also appears in smaller documents inside the grid axes (K=10 grid: 5.5 ms; records N=4,400: 63 ms).

#Other operations (3 runs each; "grouped" fixtures start with C0 groups collapsed, so rows are not K^3)

Reshape (prune off, K=10, 1000 rows): hide item 0.25 ms, set axis 0.19 ms, toggle prune 12.7 ms. K=26 grouped, 2,028 rows: hide 0.5, toggle group 1.8, set axis 1.8, toggle prune 727-750 ms. Prune on K=13 grouped, 321 rows: hide 9.4, toggle group 37, set axis 37, toggle prune 37-39 ms. Host::connect snapshot: K=10 6.5 ms, K=26 375 ms, records N=14,000 2.9 ms. Host SetCell+project: K=10 7.7 ms, K=26 ~400 ms, records N=4,400 158 ms (equal to apply + a whole-grid width walk).

#Classification and feasibility of yielding

operationclasstoday's first size over 8 msdominated bychunked/yielding form
keypress / scrollhot (<=8 ms)1,000 rows (K=10) / N=1,300 recordsrebuild_layout + whole-grid compute_col_widths (O(rows^2) nth), recomputed though nothing changednot a slicing problem: cache widths/layout across keys, O(1) row lookup (ADR 008/009). Slicing would only hide it
browser window projectionhot1,000 rowssame width walk, whole grid regardless of windowneeds the same fix; then window cost is O(window)
SetCell apply + projecthotK~19 (prune off), 1,300 recordslayout rebuild O(rows) + widths + projectionincremental update of one row/total, not a rebuild
column totalshotinside grid_of (not separable here)whole-grid sum in the projectionmaintained total (ADR 009)
open / layout builddeferrable40k rows (prune off); N~1,750 (records)build is ~90 ns/row (sliceable by outer index); the prune/records grouping is the quadratic partbuild is feasible in slices once the quadratic is removed; the quadratic itself is not sliceable
reshape (hide/collapse/axis)deferrablecheap prune-off (<2 ms to ~2k rows); 37 ms prune-on at 321 rowslayout rebuild + pruneone sort/remap at 100k occupied tuples was 2.3 ms in ADR 008's prototype, so a reshape can fit inline; prune toggle needs the quadratic fixed first
save (format_md)deferrable~700 cellsper-cell formatting, linearthe Data section is a flat per-cell line stream, so chunking by cell range with a yield every ~400 cells looks feasible, but needs a consistent snapshot (clone or journal); not profiled by function
load (parse_md)deferrable~4k cellspest parse + tree walk, lineartree walk is chunkable by line; the pest parse of one text is not, unless the Data section is parsed line by line (grammar is line oriented); not profiled by function
persistence per commanddeferrable (debounce)~700 cellsthe whole-document format_mdcoalesce/debounce or append-journal: formatting 2 s per command at target is unusable inline at any slicing

#What I could not measure