1---2title: "Bounds of support"3sidebar_position: 64---56# Bounds of support78What 012 handles well, where it degrades, and why, measured. Numbers come9from `make stress` (synthetic worst cases and real datasets, see10[Measuring](#measuring)), `make stress-e2e` (key press to screen through a11real terminal emulator), and the telemetry described in12[Observability](observability.md), which tracks how they move.1314Machine: Apple M5 Pro (18 cores), 48 GB, macOS, Go 1.27.1, arm64. Times15are single-threaded: the engine and the UI run on one goroutine. Figures16are medians of `-benchtime 1s` or `BENCHTIME=2s` runs on an otherwise17idle machine; a range means runs differed by that much, and differences18under about 5% are noise.1920The yardstick is a keystroke through to its frame: under 16 ms feels21instant (one frame at 60 Hz), under 100 ms feels responsive, past that22it lags, and past a second it stalls.2324## Summary2526| Dimension | Comfortable | Degraded | Unsupported | Dominant cost |27|---|---|---|---|---|28| Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (ten million by default): navigation, drawing and every command cost what the cells cost, not the grid; ten million numbers take 205 MB, build in 1.8 s and save in 0.54 s | Opening a `.012` file of ten million cells: 1.2 s, 311 MB of heap at its peak | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | Parsing each cell's entry from the file's JSON; heap per cell (about 20 B for numbers, 20 to 60 B for real data, 750 B for formulas) |29| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1.3 to 1.9 ms); 1000 SUMs over a full column: 0.53 ms; 8192 running totals: 2.7 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 29 ms an edit | | About 3 ns per cell read: an index into the column's block |30| Full recalc | Any sheet: numbers and text hold their values and cost nothing; 1000 full-column SUMs 0.5 ms; 8192 running totals 1.8 ms | 60 whole-column criteria functions over 8192 rows: 44 ms | | Same as above |31| Rendering | Any sheet at up to 200 x 60: 1.3 ms a frame; 400 x 120: 4.2 ms; a color scale on every cell shown adds 0.4 ms at 200 x 60, borders on every cell and wrapped text 0.4 ms | 20 charts at 400 x 120: 6 ms | | View building styled strings, then Bubble Tea parsing and diffing them |32| Tracing (`BenchmarkTrace`) | An arrow key through to its frame at 200 x 60 with tracing on, on a million cells: a chain of a million formulas 0.66 ms (0.64 off), a million numbers 0.82 ms (0.82 off), a cell read by a million formulas 1.6 ms (0.85 off); off, it costs nothing | | A cell's dependents listed past a thousand: the list says there are more | Finding a cell's links (dependents capped at a thousand), and asking each formula on screen whether it reads the cell |33| Selection statistics | Any selection: extending one over all 2.1 M cells of 8192 x 256 costs 0.3 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |34| Imports | CSV, SQLite, Parquet: 2 to 4 M cells/s (a million cells in 0.25 to 0.4 s); XLSX numbers or text: 0.8 to 1.4 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |35| Undo | One step of any size: undo costs what the edit cost; clearing all ten million cells of a full sheet holds 193 MB | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 15 MB | A step past 1 GB (millions of formulas or long distinct texts at once): asks, and runs without undo if told to | Before-images in the store's form: a slot (20 B) per plain cell, a whole `Cell` per formula |36| JEV | 4000 JEV cells: 4 us of CPU per answer, 15 ms to answer them all | | | An answer recalculates the cells that asked it; answers within a frame recalculate together |37| Formula depth | 1000 nested parentheses or IFs: under 0.5 ms; chains of formulas through every cell of a sheet | | More than 1024 levels of nesting in one formula: a parse error | Recursive parser; evaluation puts off cells past 65,536 levels |38| Macros | Replaying 1000 recorded actions: 2.2 ms, one undo step; a script's call to the sheet: about 1.4 us | | Scripts past 10 M Starlark steps: stopped, with the line | One message per call to the sheet, served in batches on the UI goroutine |39| Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |40| SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |41| Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 0.8 to 2.5 ms | | Results past the grid (the pivot shows #REF!) | Grouping the source rows |42| Linked sources | Ten million rows of Parquet or SQLite: opened in 0.4 ms or 0.65 s, a frame scrolling them 0.9 ms at 200 x 60, a page anywhere 0.2 to 12 ms; SUM of a column 0.42 s (Parquet) or 1.4 s (SQLite), COUNTIFS 1.2 or 2.3 s, a pivot table 2.5 or 3.9 s, each in a few MB; MEDIAN 1.7 or 2.8 s in 50 MB | Sorting ten million rows: 3.5 s (Parquet) or 19 s (SQLite), in the background | Functions that hold what they read, over more than `max-cells` cells: `#VALUE!`, saying why | Reading the column from the file; sorting |43| Following files | A log growing by 10,000 rows a second, formulas over it: each update (about 2,500 rows, four times a second) applied in 7 ms keeping every row, 1.7 to 2 ms keeping the last 1000, 14 ms the last 10,000; frames 2.5 to 3.6 ms at 200 x 60 | | Rows past `max-cells`: left out, with a note | Writing the rows and recalculating what reads them; a window rewrites its rows on every update |44| Notebook streams (`TestStreamGridAtFrameSpeed`) | 10,000 rows arriving in batches of 250, the output's grid on screen: 2.3 ms a batch through to its frame at 200 x 60 (3.6 ms at worst) | Past 20,000 rows in one run: the grid is read again from the 10,000 the output keeps, about 60 ms once every 10,000 rows | | Reading the batch's rows into the grid and measuring their text; the output's NUON copied once a batch |45| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 106 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |4647## Sheet size4849Hard limits: `sheet.MaxRows = 1,048,576`, `sheet.MaxCols = 16,384`50(A..XFD), as in Excel. Memory is bounded by `max-cells` (the config file,51ten million cells by default): imports keep whole rows up to it, pastes52and fills write at most that many cells at once. WK1 files keep their own538192 x 256. The costs below grow with the cells; the grid itself costs54nothing (see [The grid](#the-grid)). A `.012` file is read and written55as a stream, so opening costs about the memory the workbook then holds56([The .012 file](#the-012-file)).5758| Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) | 1,000,000 x 10 (10 M cells) |59|---|---|---|---|60| Build through `Load`, then full recalc | 48 ms | 0.47 s | 1.8 s |61| Live heap | 4.4 MB | 43 MB (20.5 B per number cell) | 205 MB |62| Save `.012` (JSON, one line per cell; heap used at the peak) | 12 ms, 4.6 MB (0.3 MB) | 0.14 s, 47 MB (0.1 MB) | 0.54 s, 236 MB (0.7 MB) |63| Open `.012` (heap at the peak, the workbook included) | 24 ms (15 MB) | 0.24 s (60 MB) | 1.2 s (311 MB; 0.9 GB resident, the file's bytes and a sheet built beside it included) |64| Export CSV / TSV / XLSX / SQLite | 33 / 31 / 45 / 38 ms | 0.51 / 0.52 / 0.65 / 0.68 s | |65| Edit a number, undo and redo it | 0.6 us, 1.2 us | | 0.6 us, 1.2 us |66| Frame at 200 x 60, whole | 0.80 ms | 0.80 ms | |6768XLSX exports are written by 012 with archive/zip: 1.9 MB allocated for698192 x 26 and 17 MB for 8192 x 256, whose time is mostly compression.7071Heap per non-blank cell after loading (`BenchmarkMemory`,72`BenchmarkImport`): numbers 20.5 B, opened from a file too; formulas 75073B (the parsed tree and reference lists); imported CSV, SQLite and74Parquet 21 to 63 B; text about 60 B plus its length, once per distinct string75(a column of 90 texts repeated takes 27 B a cell). A plain cell (a76number, boolean or text as typed, with a format and style) is a 16-byte77slot in its column's block of 1024 rows, and so is a derived cell, what78a pivot or a spill writes: a pivot's results take 20.6 B a cell and79spilled numbers 20.5 B, plus the 32 B a value of the array the anchor80keeps to compare with the next one (`BenchmarkMemoryDerived`). Formulas81and notes are whole `Cell`s, about 300 B each with their entry and82input (the `Cell` itself is 216 B). How the store lays83them out is in [Architecture](architecture.md#the-engine). Reading a84cell's value is an index into its column's block: 2.8 ns85(`BenchmarkRead`), 8.6 ns a cell read by a SUM in a full recalculation.8687### The .012 file8889The reader and writer stream (`internal/sheet/fileread.go`,90`filescan.go`): each cell goes into its sheet as its line is read, a91number typed plainly straight into its 16-byte slot, and the other92fields are small and decoded whole. Saving writes the cells in93row-major order as it reads them from the sheet, straight into the94file.9596There is one format. A binary one that stored the slots themselves97would open ten million numbers in about 0.3 s at best (storing ten98million slots alone takes 0.17 s) where the JSON takes 1.2 s: a second99saved on sheets near `max-cells`, and nothing a smaller sheet would100notice, for the loss of what the JSON gives, diffs, merges, and a file101anyone can read and fix.102103## The grid104105Nothing costs what the grid costs. The cells' occupancy106indexes (bitmaps of 1024 rows per column) say which cells are stored and107which have contents; ranges are read through them, so a whole column of108ten cells is ten visits. Formats of whole columns, rows and the sheet109live on the lines, not on cells. `TestCommandsCostTheDataNotTheGrid`110(`internal/ui`) runs every command with the whole sheet, a whole column111and a whole row selected on the full grid and fails past 250 ms or 8 MB;112each takes 0.1 to 4 ms.113114What each part of 012 visits when given a range or selection:115116| Where | Visits |117|---|---|118| Range reads for aggregates (`Book.Fold` and `Book.Scan`) | the stored cells, in row order |119| SUMIF(S), COUNTIF(S), AVERAGEIF(S), SUMPRODUCT, COUNTBLANK | the cells any range holds; the blanks between counted at once |120| MATCH, VLOOKUP, HLOOKUP, XLOOKUP | the stored entries, the blanks between as one; ROWS and INDEX keep the full size |121| Range users (`rangeIndex`), found per changed cell | interval trees per used column, whole rows in one |122| Formatting whole columns or rows | column, row and sheet formats |123| Used range, Ctrl+arrow, text running into view | the index of filled cells |124| Filter over whole columns | rows with data, one test for the blank rest, stepped over at once |125| Sort | the rows with cells |126| Paste, fill of blanks | the clip's cells per tile; what blanks land on is cleared |127| Formats pasted or moved | whole columns and rows: their line formats, and the cells where formatted lines cross them; a block: its blank cells get formats of their own up to 65,536 cells, then only the stored cells do |128| Formulas re-inferring after a line's format changes | the dependency indexes, not the line's cells |129| Copy to the system clipboard | the cells; refused past `max-cells` |130| Pivot sources, JEV value ranges, chart detection | the rows with data; JEV ranges past 4096 cells trimmed to their data |131| Exports of a selection, script reads | trimmed to the data |132| Row numbers, name box | 7 digits (the header widens past 9999), XFD |133| Column width reset, set | one undo step; a reset touches only columns with a width |134| Row heights set, fit | one undo step; kept per row, so more than 65,536 rows (whole columns) stop at the last row holding a cell; a fit touches only rows with a height |135| Borders over whole columns or rows | their line formats, and the cells where formatted lines cross them; the facing edges of the neighbors along the outline |136| Merging | the cells the range holds; one change makes at most 10,000 merged cells, so merging a whole column's rows is refused |137| Drawing wrapped text, borders and merges | the rows on screen: the cells of each whose own style wraps or draws borders, from an index by row, and the merges crossing it |138| XLSX column widths and styles | the data's width (at least 256), a style on A and XFD as the sheet's |139140A sheet of 10,000 numbers spread down a million rows under 3000141whole-column formulas (sparse-1M) edits in 1.6 ms, draws a 200 x 60142frame in 0.68 ms, and answers arrow keys at XFD1048576 in 0.65 ms.143Typing an entry under 1000 SUMs over a column, through to its frame at144200 x 60, takes 0.91 ms.145146The index costs a few percent on edits and loads (keeping two bitmaps147current per cell), and running aggregates cost memory for their148checkpoints (a 1000-SUM fan-in allocates 2 MB per recalculation). A149range is shared only from its second read in a recalculation, so ranges150read once cost nothing extra.151152## Formula topologies153154`BenchmarkEdit` sets one cell and recalculates what depends on it;155`BenchmarkRecalcAll` recomputes everything. 8192 is a full column.156157| Topology | Edit | Full recalc | Undo + redo |158|---|---|---|---|159| Independent cell in 8192 x 26 numbers | 0.7 us | nothing to compute | 1.2 us |160| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.9 ms | 1 ms | 3.8 ms |161| Fan-out: one cell read by 8192 formulas | 1.3 ms | 0.8 ms | 2.6 ms |162| Volatile: 8192 TODAY() and RAND() | 1.3 ms | 0.8 ms | 2.6 ms |163| 1000 named ranges, one formula each | 55 us | 1.2 ms | 110 us |164| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.53 ms | 0.48 ms | 1.1 to 1.2 ms |165| Running totals: 8192 x SUM($A$1:An) | 2.7 ms | 1.8 ms | 5.8 ms |166| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 1.6 ms | 1.5 ms | 3.2 ms |167| Criteria: 60 SUMIF, COUNTIFS and AVERAGEIF over whole columns of 8192 rows | 29 ms | 44 ms | 59 ms |168| Lookup: 300 VLOOKUP, MATCH and XLOOKUP into 8192 keys | 12 ms | 12 ms | 27 ms |169| 10 M numbers (1,000,000 x 10) | 0.7 us | nothing to compute | 1.2 us |170171A full recalculation computes only formulas and what pivots and spills172write: numbers and text hold their values from the moment they are173stored. Every volatile formula is recomputed on every change, as in174Sheets, so 8192 volatile cells add about 1.2 ms to every edit anywhere. Criteria175and lookups cost what their ranges hold, not what the grid holds: the176criteria collect positions from the occupancy index, and lookups walk177the stored entries with a cursor.178179Through a real terminal (`make stress-e2e`: 200 x 60, 8192 x 26 imported,180200 SUMs over column A): arrow keys p50 8.3 ms, p95 10.2 ms; an entry181that recalculates the 200 SUMs p50 62 to 70 ms, p95 67 to 75 ms. The182arrow floor is Bubble Tea's renderer, which draws at most 120 frames a183second (`ui.FrameRate`, its maximum; the default is 60), so a key waits184up to 8 ms for the next frame; 012's own share of an arrow key is 0.2185to 1 ms. At 60 frames a second the same arrows take p50 16.6 ms, p9518618.4 ms. The renderer writes a frame only when the view changed, so an187idle 012 costs the same at either rate: 0.6 to 2.2 ms of CPU a second188on an empty sheet over 30 s, within the noise of measuring it.189190## The function library191192Functions live in `internal/functions`, behind a `Book` interface whose193methods pass only values (see [Architecture](architecture.md#functions)):194SUM-like functions have the engine add a range up (`Fold`), other range195walks read chunks into buffers the `Reader` reuses (`Scan`), and lookups196walk positions with a cursor, so no function makes a call or an197allocation per cell it reads. The criteria and lookup shapes in198[Formula topologies](#formula-topologies) exercise the paths other than199`Fold`. Plain arithmetic, 1000 formulas and a SUM, recalculates in 20 us200in binary floating point and 156 us in decimal.201202## Rendering203204Only visible rows and columns are drawn, so frame time depends on the205terminal size, not the sheet. A frame is `View` plus what Bubble Tea206does with its string: parse it into a cell buffer and diff it onto the207terminal (`BenchmarkFrame`).208209| Sheet | 80 x 24 | 200 x 60 | 400 x 120 |210|---|---|---|---|211| Empty | 0.14 ms | 0.63 ms | 2.3 ms |212| 8192 x 256 numbers | 0.17 ms | 0.80 ms | 3.1 ms |213| 8192 rows of 500-character unicode text | 0.30 ms | 1.26 ms | 4.2 ms |214| 1000 named ranges | 0.24 ms | 0.73 ms | 2.4 ms |215| 20 charts (text) | 0.78 ms | 2.7 ms | 6.0 ms |216217More than half of a frame at 400 x 120 is Bubble Tea's parse and diff of the218view string.219220Conditional formats and validation cost what the screen shows. A cell's221look is worked out when it's drawn and kept until the next222recalculation (at most 65,536 cells, then the cache starts over), and a223color scale reads its range's numbers once per recalculation: its224lowest and highest, and a percentile by selection, not by sorting.225With a 3-point color scale over all of 8192 x 26 numbers (213 k cells,226every cell on screen a shade), at 200 x 60 (`BenchmarkFrame`,227`BenchmarkKeystroke`, `scale-8192x26`):228229| | Without rules | With the scale |230|---|---|---|231| A frame, or an arrow key through to it | 0.80 ms | 1.2 ms |232| Typing a number and Enter, through to the frame | 0.81 ms | 2.2 ms (the scale's percentile over 213 k numbers) |233234Shades and rule colors keep their escape codes, so a plain cell on one235costs a string concatenation, not a style render. A custom formula is236evaluated for each cell drawn, once per recalculation, with its237references moved for the cell. Top values, averages and duplicates238read their ranges once per recalculation, as a scale does.239240Data bars and icons are drawn a column at a time, their glyphs with the241escape codes of their roles kept for the frame, so a cell under one costs242more than a shade. `bars-8192x26` puts a data bar over24313 columns of 8192 x 26 numbers and arrows over the other 13244(`BenchmarkFrame`):245246| | 80 x 24 | 200 x 60 | 400 x 120 |247|---|---|---|---|248| 8192 x 26 numbers, a frame | 0.17 ms | 0.80 ms | 2.8 ms |249| The same with bars and icons on every cell | 0.26 ms | 1.4 ms | 4.6 ms |250251Wrapped text, borders, row heights and merged cells cost what the252screen shows too. A sheet with none of them is drawn a line per row253without asking more; with them, each row drawn is measured once per254change to the sheet (the text its cells wrap, from an index of the cells255that wrap or draw borders, and whether a border lies along its top), and256a frame keeps each border string it has drawn in a role for the rest of257the frame. `laidout-8192x26` borders every cell of 8192 x 26 numbers,258wraps a column of notes over three lines and merges a title across the259top (`BenchmarkFrame`, `BenchmarkKeystroke`):260261| | 80 x 24 | 200 x 60 | 400 x 120 |262|---|---|---|---|263| 8192 x 26 numbers, a frame | 0.17 ms | 0.80 ms | 2.8 ms |264| The same laid out, a frame | 0.24 ms | 1.22 ms | 4.1 ms |265| The same laid out, an arrow key through to its frame | 0.25 ms | 1.23 ms | |266267A chart drawn as text costs 4 to 50 us at 24 x 10 to 120 x 40 cells,268whatever its data: only the categories that fit are drawn (a pie of 8192269slices, 0.44 ms, is the worst). An image for kitty graphics is redrawn270when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and2711 to 1.5 ms at 120 x 40, a pie 8.6 ms (`internal/chart`,272`BenchmarkDraw`, `BenchmarkImage`). A sixel image is encoded then too,273in 0.07 to 0.3 ms at 24 x 10 cells and 3 to 7 ms at 120 x 40 (10 x 20274pixel cells), 0.6 to 67 KB to send (`BenchmarkSixel`); it is sent again,275without encoding, whenever the screen under it is redrawn. Key presses add little: an arrow key276through to its frame is 0.17 ms at 80 x 24 and 0.8 ms at 200 x 60;277Page Down 1.0 ms.278279## Selection statistics280281With the whole of an 8192 x 256 sheet selected, the status line's Sum,282Avg and Count cover 2.1 M cells. On a sheet of more than 16,384 cells,283a selection of more than 4096 is summed from an index of each column's284sum, count and count of numbers (`internal/sheet/stats.go`), kept on the285blocks of the index of filled cells: an entry per 64 rows and a total286per 1024, made only where there is data. A selection adds up the whole287blocks it covers, plus the rows at its ends a cell at a time. The index288is built on first use, in one pass over the cells, and a stored, deleted289or recalculated cell marks its block to be summed again when a selection290next covers it. Sums are recomputed, never adjusted, so they don't291drift. The result is cached until a cell changes. Each key that changes292a selection over the whole sheet, through to its frame293(`BenchmarkKeystroke`):294295| Key, 8192 x 256 numbers | 80 x 24 | 200 x 60 |296|---|---|---|297| Shift+Up and Shift+Down with the data selected (`extend-data`) | 0.33 ms | 1.85 ms |298| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 0.29 ms | 1.56 ms |299| Ctrl+A, the selection standing (`select-data`) | 0.29 ms | 1.78 ms |300301The index costs memory only on sheets that had a large selection, in302proportion to their data, and a nil check per stored or recalculated303cell.304305## Imports306307Real, openly licensed datasets fetched by `scripts/stress-data.sh`308(sources, licenses and checksums in `scripts/stress-data.tsv`):309310| File | Shape | Import | Throughput | Heap per cell |311|---|---|---|---|---|312| OWID energy (CSV) | 23,377 x 130, 1.04 M cells | 0.25 s | 4.2 M cells/s | 23 B |313| Airport codes (CSV) | 86,134 x 13, 855 k cells | 0.39 s | 2.2 M cells/s | 55 B |314| NOAA daily CO2 (CSV) | 18,304 x 2 | 12 ms | 3.1 M cells/s | 63 B |315| Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.4 ms | 2.3 M cells/s | 59 B |316| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 8.5 to 10 ms | 380 to 450 k cells/s | 239 B |317| Chinook PlaylistTrack (SQLite) | 8715 x 2 | 6.1 ms | 2.9 M cells/s | 21 B |318| Parquet alltypes_tiny_pages | 7300 x 13 | 27 ms | 3.5 M cells/s | 44 B |319320Past `max-cells` (or the grid), imports keep whole rows up to it and321say how many rows they left out. Parquet files and SQLite tables stop322reading at the last row and take the count of the rest from the file;323CSV, TSV and SQLite queries are read to their end to count it, without324keeping it. Every importer streams, so memory follows the sheet, not the325file. 012's XLSX reader streams a worksheet a token at a time, keeping326only the shared strings (in one buffer, 4 bytes a string besides the327text), the cell formats, and the first cell of each shared formula.328XLSX imports set column widths and styles for the data's columns (at329least 256) without an undo step each.330331XLSX imports of full sheets written by 012's exporter332(`BenchmarkImportXLSX`) and the POI file. Peak heap is sampled while333importing, per cell kept, and includes garbage not yet collected:334335| Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |336|---|---|---|---|---|---|337| 8192 x 26 numbers (213 k cells) | 147 to 154 ms | 171 MB | 3.3 M | 20.5 B | 114 B |338| 8192 x 26 table, text and numbers | 262 to 275 ms | 246 MB | 7.7 M | 21 B | 119 B |339| 8192 formulas (`=A1+1` down a column) | 14.5 ms | 18.6 MB | 321 k | 755 B | 2061 B |340| Apache POI formula tests | 8.5 to 10 ms | 9 MB | 162 k | 239 B | 965 B |341342The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),343with a message naming the limit: 10,000 files in the zip; 1 GB344uncompressed in one part and 2 GB in all, counted as the bytes come out345whatever the zip's headers say; a part compressed more than 250 to 1346once past 16 MB (a zip bomb); XML nested more than 256 deep or a single347tag or text over 32 MB; 16.7 M shared strings; 65,536 cell formats,348fonts, number formats or names; 4096 sheets; 256 MB of text from349expanding shared formulas; rows past 1,048,576, columns past XFD and350rows of more than 16,384 cells; part names that are absolute, climb with351`..` or hold a backslash. encoding/xml expands no external or declared352entities, so entity bombs fail as unknown entities.353354## Following files355356`BenchmarkFollow` (`internal/ui/followstress_test.go`) follows a CSV357log while another goroutine appends 10,000 rows a second to it for three358seconds, with `=SUM(D:D)`, `=COUNTIF(B:B,"error")` and an AVERAGE over359the region, polling as the UI does and drawing a 200 x 60 frame after360each update:361362| Rows kept | Update applied (slowest) | Frame (slowest) | Rows a second taken in |363|---|---|---|---|364| Every row (30,000) | 6.7 to 6.8 ms | 3.2 to 3.6 ms | 8,200 |365| The last 1000 | 1.7 to 2 ms | 2.8 to 3.2 ms | 8,300 |366| The last 10,000 | 13.8 to 13.9 ms | 2.6 to 3 ms | 8,000 |367368(Rows a second counts to the last row read, after the writer stopped;369the writer itself manages a little under 10,000 with its pauses.) An370update costs the rows it brings, and with a window the rows it keeps:371the window's rows move up one row each on every update, so a window of372100,000 rows under a fast log would take about 140 ms an update. A373followed file costs one `os.Stat` four times a second while it doesn't374change; a growing file is read a megabyte a poll at most, so a large375file loads over several frames. Rows are held once, in the region's376cells; a window reads the rows it keeps back from them.377378## Linked sources379380A [linked source](../files/sources.md) is read in place: its tab keeps381at most 48 pages of 128 rows, and what formulas and pivot tables ask382of it is worked out by streaming the file in the background, a few383questions at a time, each answer kept until the file changes. None of384it costs what the source's size does in memory. Measured on ten million385rows of sales (an id, one of eight categories, an amount and a date;386`stress.SalesParquet` and `stress.SalesSQLite`, 204 MB and 385 MB), one387run each, the peak heap above what was live before:388389| Reading the file (`BenchmarkSource`, `internal/fileio`) | Parquet | SQLite |390|---|---|---|391| Open | 0.39 ms, 0.2 MB | 0.65 s (counting the rows) |392| Scan one column | 0.35 s (28.5 M rows/s), 4.5 MB | 1.36 s (7.4 M rows/s), 3.4 MB |393| A page of 60 rows deep in, in the source's order | 0.28 ms | 0.19 ms |394| Sorting by a column, Z to A | 3.5 s, 113 MB | 19.1 s, in SQLite's own memory and files |395| A page deep in, sorted | 12 ms (the rows are scattered) | 0.41 ms |396| Filtering to one category (1,248,497 rows) | 1.26 s, 6.7 MB | 1.19 s |397| A page deep in, filtered | 0.23 ms | 0.23 ms |398399| Answering a question (`BenchmarkSourceFormulas`, `internal/paged`) | Parquet | SQLite |400|---|---|---|401| `SUM`, `AVERAGE` of a column | 0.42 s, 3.8 MB | 1.42 s, 2.6 MB |402| `COUNTIFS`, `SUMIFS` over two columns | 1.2 s, 8.3 MB | 2.3 s, 7.5 MB |403| `SUMPRODUCT` of two columns | 0.44 s, 9 MB | 1.56 s, 7.7 MB |404| `XLOOKUP` of the last id | 0.62 s, 5.9 MB | 1.28 s, 4.3 MB |405| `MATCH` of the middle id | 0.31 s, 5.9 MB | 0.63 s, 3.8 MB |406| `MEDIAN`, sorting the column on disk | 1.71 s, 50 MB | 2.75 s, 49 MB |407| `PERCENTILE`, `LARGE`, `MODE`, the same sort | 1.86 to 1.93 s, 50 MB | 2.93 to 2.97 s, 49 MB |408| `RANK`, counting as it reads | 0.40 s, 5.7 MB | 1.39 s, 4.8 MB |409| `STDEV`, which holds every value: ten million cells are within `max-cells` | 0.43 s, 248 MB | 1.42 s, 247 MB |410| A pivot table: SUM and COUNT of the amounts by category | 2.45 s, 5.9 MB | 3.9 s, 2.7 MB |411412SQLite's own memory, through modernc's libc, isn't in the Go heap.413Functions over aligned ranges (the criteria functions, `SUMPRODUCT`)414read their ranges a window of 2048 rows at a time, each column415streamed by a goroutine of its own in batches of 4096 rows. The order416statistics (`MEDIAN`, `PERCENTILE`, `QUARTILE`, `LARGE`, `SMALL`,417`MODE`) of one column stream its numbers into the external merge sort a418Parquet view's sort uses, in runs of a million numbers (16 MB) spilled419beside the system's temporary files, 160 MB of them for ten million420rows, then read back in order once, picking what they need as they421pass; the runs are removed as the answer is found. SQLite's own422`ORDER BY amount LIMIT 2 OFFSET 4999999` takes 19 s on the same table,423so a SQLite source is read and sorted the same way. A function that424holds what it reads is refused past `max-cells` cells before it reads425any, so `STDEV` of a hundred million values says so at once rather426than holding them.427428Scrolling (`BenchmarkSourceScroll`, `internal/ui`) draws a frame in4290.26 ms at 80 x 24 and 0.9 ms at 200 x 60 within the pages read, as a430sheet's frame costs. A jump anywhere, as dragging the scrollbar makes,431reads the pages it shows and a window either side, in the background432while the frames go on showing `…`: 0.64 ms at 80 x 24 and 1.5 ms at433200 x 60, the pages read and the frame drawn.434The speed gate holds a frame on a 200,000-row source and a SUM over it435to their baselines (`frame/source-200000x4`, `source/sum-200000`).436437## Undo438439Each step keeps the cells it changed as they were, in the form the440sheet keeps them (`historyimage.go`): a plain cell as its 16-byte slot441in columns of blocks, its text and formatting in the step's own tables,442and a whole `Cell` only for a formula or a note. A step443of one cell or a few keeps them in a list. Undoing costs about what the444change cost, since it recalculates the same cells. Clearing 213 k cells445and undoing it takes 86 ms; clearing all ten million cells of a full446`max-cells` sheet of numbers holds 193 MB, and with its undo takes 6.2447s. A deleted or replaced sheet is kept whole, so its step costs nothing448more.449450The history is bounded by steps (`MaxUndo = 100`) and by an estimate of451the memory its before-images hold (`MaxUndoBytes = 256 MB`); past either,452the oldest steps are dropped first, and the newest step is kept however453large it is. Each cell is counted as it is recorded (a slot, a block's454share, its text once per step, more for a formula, whose parsed tree455stays alive), so the budget costs nothing to check. The estimate follows456the measured heap within about 10% (`BenchmarkHistoryFull`,457`BenchmarkHistoryWide`, `BenchmarkClearMax`):458459| Benchmark | Result |460|---|---|461| 100 edits rewriting a whole column (819 k before-images) | 15.9 MB held (15.9 MB estimated), all 100 kept |462| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 49.3 MB held (49.4 MB estimated), all 12 kept |463| Clearing all of 1,000,000 x 10 numbers | 193 MB held (193 MB estimated) |464| Undo + redo of one cell (dense, names) | 1.2 us, 109 us |465466A change whose step would hold more than `MaxStepBytes` (1 GB), as467`Sheet.UndoCost` estimates it before the change (a slot a cell, the468formulas whole, the range's share of the strings), asks first on the469context line: "This can't be undone: it would take N MB of undo470history." Enter runs it `WithoutUndo`, recording nothing and forgetting471the history, which could no longer be undone past it. Plain cells cost472about 20 B each, so only millions of formulas or of long distinct texts473changed at once come near it.474475## JEV476477Each JEV formula is volatile: every recalculation looks its question up478in the cache. A formula shown Loading… is noted against the question it479waits for (by `RemoteCall.Key`), and an answer recalculates only the480formulas waiting for it and what reads them481(`Workbook.RecalcAnswered`). Answers are stored as they arrive; the482first since the last recalculation schedules one a frame later483(`ui.FrameInterval`), which covers every answer stored meanwhile, so484answers arriving together recalculate once. With a fake client485answering instantly (`BenchmarkJEV`; the benchmark doesn't wait the486frame, so answers queued together, up to 8, recalculate together):487488| JEV cells | Per answer | All answers |489|---|---|---|490| 100 | 3.5 us | 0.35 ms |491| 1000 | 3.6 us | 3.6 ms |492| 4000 | 3.8 us | 14 to 15 ms |493494Against the real service the network dominates: at most 8 questions are495in flight, so 4000 questions at 300 ms each take 2.5 minutes, while the496recalculation costs a few microseconds per answer whatever the number497of JEV cells. Other changes recalculate every JEV cell, as every498volatile formula, looking the answers up in the cache.499500## Formula depth501502The parser (`internal/formula`) counts nesting as it recurses:503parentheses, function calls and prefix operators each open a level. Past504`formula.MaxDepth`, 1024 levels, a formula fails to parse with "Formula505is nested too deeply (more than 1024 levels)", shown on the context line506with the caret at the level past the cap, as other parse errors are.507Excel allows 64 nested functions; 1024 is far beyond a formula written508by hand, and bounds the recursion of everything that walks a formula509(the printer, reference rewriting, the evaluator within one formula). A510`.012` file holding a deeper formula fails to open with that message and511the cell; imports keep such a formula's text or cached value, as for any512formula they can't read.513514Evaluation recurses once per link of a chain of formulas, which no515parser cap bounds, so it counts its depth in cells and operators: a cell516reached past 65,536 levels is put off. The evaluation in progress is517abandoned (its cells go back to dirty), the cell is evaluated from the518top of the stack, and the abandoned evaluation is retried and finds it519done. Values are the same as with unbounded recursion, a cycle longer520than the limit is still a cycle, and the stack stays under 32 MB521(`TestFullSheetChain`, run with `SHEET_FULL_CHAIN=1`, checks it under a52264 MB limit: 4 s for a chain through all 2.1 M cells, building523included).524525| Benchmark | Time |526|---|---|527| Parse (`BenchmarkParse`) | 2.4 us |528| 1000 nested parentheses / IFs, parse and evaluate | 203 / 438 us |529| 10,000 nested parentheses / IFs | 1.1 / 2.0 ms to refuse |530531## Several sheets532533A workbook recalculates all its sheets together. Formulas that name a534sheet (`Data!A1`) are indexed by the sheet names they use: a changed cell535on a sheet some formula names scans those cross-sheet formulas, so a536change there costs O(changed cells x cross-sheet formulas); cells on537sheets no formula names skip the scan. Each sheet's lookup, which538resolves sheet names, is made once per recalculation, and the539recalculation state stays on each sheet keyed by address. Every cell540read goes through one function call to resolve its sheet, 3 to 5% of a541read-heavy recalculation.542543| Benchmark (`internal/sheet`, `go test -bench SheetEdit`) | Edit |544|---|---|545| 5000 formulas on one sheet reading `A(n)` and `SUM(A1:A10)` | 1.65 ms |546| The same 5000 formulas reading `Data!A(n)` and `SUM(Data!A1:A10)` | 2 ms |547548## Serving over SSH549550`BenchmarkSessions` (`internal/serve/stress_test.go`) opens N sessions,551each over its own SSH connection on loopback at 120 x 40, optionally552opening the same 1000 x 26 sheet of numbers in every one, a copy each553(`--share off`) or shared in one room, then has every554session press an arrow key at once, 200 times, timing each key from the555client's write to the first bytes of its frame arriving. The clients556run in the same process, so the heap and CPU figures include their side557of the connections and are upper bounds for the server. Sessions ask558for 120 frames a second, as the local app does (`-benchtime 200x`):559560| Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |561|---|---|---|---|562| 10, new sheet | 1.35 MiB | 5.1 / 8.3 / 9.8 ms | 0.83 ms |563| 10, 1000 x 26 numbers | 1.9 MiB | 3.9 / 8.3 / 10.8 ms | 0.79 ms |564| 50, new sheet | 1.30 MiB | 5.0 / 8.7 / 17.8 ms | 0.53 ms |565| 50, 1000 x 26 numbers | 1.9 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |566| 10, 1000 x 26 numbers, shared | 1.5 MiB | 5.0 / 8.3 / 16.6 ms | 1.27 ms |567| 50, 1000 x 26 numbers, shared | 1.5 MiB | 7.1 / 15.8 / 33.2 ms | 0.79 ms |568569Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame570pacing (at most one frame every 8.3 ms, so a key waits half a frame on571average), not load. A heap profile with 50 sessions open572(`SERVE_HEAP_PROFILE=file`) puts three quarters of a session's heap in573Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the574window), 130 KiB in its input key table and under 50 KiB in both ends575of the SSH connection, so the figure is close to the server's own. On576top come 20 to 60 B per cell of data of the sheets a session has open;577sessions opening the same file each hold a copy. CPU per frame covers578both ends of the connection (the encryption twice, the client reading579the frame); at 0.7 ms, 50 sessions typing continuously at 120 frames a580second would keep about four cores busy: frames are drawn only when a581session's screen changes, so the rate is the most keys can cause, not a582cost of being connected.583584Sessions sharing a workbook hold it once, so each holds only its585screen. They take turns on it: each key is handled under the room's586lock, and moves a pointer every other session draws, so a key costs a587frame in every session of the room (CPU per frame counts the pressing588session's alone), and fifty sessions pressing keys in the same589millisecond queue for the lock, the last waiting about two frames. A590change reaching the others is quick next to the frame: from the end of591one session's turn to another's frame showing it takes about 0.1 ms592(`TestSharedEditWithinAFrame`), so it goes out with the other's next593frame.594595## Pivot tables596597A pivot table is recomputed whole whenever a cell of its source range is598recalculated: it reads every source row once, groups it by a comparable599key struct (no string built per row), and rewrites only the result cells600that changed, so what reads the results recalculates only when they601move. `BenchmarkPivot` edits one cell of a full 8191-row table (an id, a602category of 8, a number and one of 1000 items, twice) under a pivot and603times the edit, the recalculation and the pivot together:604605| Pivot | Result rows | Edit | Allocations |606|---|---|---|---|607| Rows: 8 categories; SUM and COUNTA | 10 | 0.79 ms | 196 |608| Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.2 ms | 2.5 k |609| Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 2.5 ms | 14 k |610| Frequency table of the 1000 items | 1001 | 1.8 ms | 24 k |611612## Arrays and spills613614An array stores only its block holding data: a whole column read as one615(`FILTER(A:A, ...)`, `SORT(B:B)`) holds the rows with data, and the616blank rows past them are one fill value, spilled as nothing. A range617read whole is read once per recalculation and shared by every formula618reading it (`Reader.Forget`), so a thousand FILTERs of one column read619it once. An array stores at most `maxArray` values (2,097,152; about 64620MB), past which the formula is `#VALUE!`; a spill writes at most621`max-cells` cells. Spilled cells are written after each evaluation pass622(`spill.go`), only those that changed, and not at all when an anchor623computes the array it already spilled with nothing in its cells624changed; formulas reading spilled cells that changed are recalculated in625another pass, at most 64 of them.626627`BenchmarkEdit/arrays-1000xFILTER8192` (`internal/stress`: a column of6288192 numbers beside eight categories, 500 FILTERs of the numbers by a629category, 1024 rows each, and 500 UNIQUEs of the categories, all from630row 1, 516 k spilled cells) edits one number, recomputing the 500631FILTERs, 63 of whose arrays change:632633| Measure | Time | Allocated |634|---|---|---|635| Edit a number | 52 ms | 161 MB, 7.8 k allocations |636| Undo and redo it | 106 ms | |637| Full recalculation | 106 ms | |638| Build (load and recalculate) | 181 ms | |639640Most of an edit is the FILTERs' own work: comparing 8192 categories each641(`B1:B8192="alpha"`, about 20 ns a value) and picking the rows that642pass. Array arithmetic allocates the array it computes, so an edit643allocates about 32 bytes per value its conditions compare.644645## Macros646647A macro's script runs on its own goroutine; every call it makes to the648sheet (an entry, a move, a command) is served on the UI goroutine as a649message, in batches of up to 12 ms per update, so replaying doesn't wait650a frame per call and the screen stays live for Esc. The run is one undo651step whatever its size (`BenchmarkMacroReplay`, `BenchmarkMacroScript`,652`internal/ui`, `-tags stress`, `BENCHTIME=2s`):653654| Benchmark | Time | Per action or call | Allocations |655|---|---|---|---|656| Replay 1000 recorded actions, absolute references (entries, selects, extends, a command) | 2.16 ms | 2.2 us | 30 k, 1.6 MB |657| The same with relative references (moves, formulas moved with the active cell) | 2.19 ms | 2.2 us | 31 k, 1.7 MB |658| A script's loop: 5000 `set` and 5000 `get` calls | 13.7 ms | 1.4 us per call | 220 k, 7.8 MB |659660Reading a range walks its cells; a range larger than 4096 cells (whole661columns) is first trimmed to its last row and column with contents,662found through the sheet's index. Every run stops after 10663million Starlark steps (`macro.DefaultMaxSteps`), about a second of pure664computation.665666## What would raise the bounds667668Sizes and scheduling are in the [roadmap](../../ROADMAP.md#1-scale).669`max-cells` stands at ten million, as Google Sheets' limit does; the670sheet itself would hold several times that in a few GB, and opening,671saving and undoing cost about what the cells do ([Sheet size](#sheet-size),672[Undo](#undo)). Derived cells cost what plain cells cost, a slot each673([Sheet size](#sheet-size)); what stays large is a formula, about 750 B674with its parsed tree and reference lists, and the array a spilling675formula keeps, 32 B a value.676677## Measuring678679```sh680make stress # fetch datasets, run everything, record the run (5 minutes)681BENCH='Edit|Frame' make stress # a subset; BENCHTIME=2s for steadier numbers682make stress-report # latest against previous, baseline and last release, with trends683make stress-e2e # key press to screen through libghostty684```685686`make stress` fetches the datasets with `scripts/stress-data.sh`, which687checks each against its checksum in `scripts/stress-data.tsv`.688`TestCommandsCostTheDataNotTheGrid` runs with `go test ./internal/ui`689(it skips under `-short`) and guards the grid: a command whose cost grows690with its selection's area fails it.691692Benchmarks are built only with `-tags stress`, so `go test ./...` stays693fast. They live in `internal/sheet/stress_test.go` (engine),694`internal/sheet/hotpath_stress_test.go` (parsing and number formats),695`internal/ui/stress_test.go` (View, frames, keystrokes, JEV, telemetry696overhead), `internal/fileio/stress_test.go` (imports and exports),697`internal/chart/stress_test.go` (charts as text and images),698`internal/serve/stress_test.go` (concurrent SSH sessions) and699`e2e/stress_test.go`; the synthetic sheets are built by700`internal/stress`, up to `dense-1Mx10`, ten million numbers, the701default `max-cells`. Results accumulate in `.deps/stress/results` and, with702the observability stack up, in ClickHouse.703704### The speed gate705706`make speed`, part of `make check`, catches a clear slowdown before a707push rather than at the next stress run. `TestSpeed`708(`internal/ui/speed_test.go`) builds mid-sized sheets with709`internal/stress` and draws frames through the stress benchmarks' fake710terminal, so it measures what `BenchmarkKeystroke` and `BenchmarkEdit`711do, on fewer cases and briefly: an arrow key through to its frame at712200 x 60 (dense, laid out, color scale and chart sheets of 8192 rows,713and a notebook output's grid of 100,000 rows, entered), single edits714(fan-in, a chain, criteria functions, lookups) and full recalculations715(running totals, arrays). It takes about two seconds.716717Each case is held to `internal/ui/testdata/speed.json` two ways:718719- **Allocations** an operation, counted with `testing.AllocsPerRun`,720 so the same code gives the same count on any machine and under any721 load. A case fails past 1.25 times its baseline plus 16.722- **Time** relative to a calibration workload (formatting numbers,723 string map lookups, sorting) timed beside it in the same process,724 the fastest of seven timings of each, so a slower or busier machine725 slows both. A case fails past twice its baseline's ratio. Ratios726 still differ between CPUs, so they are compared only on the727 baseline's OS and architecture (the file records them); elsewhere728 only allocations are.729730A case over its margin is measured once more before it fails. Runs on731one machine vary by up to about a third, under load from other tests732too, well inside the margin; the margins are for regressions of a733multiple, and finer ones are what `make stress-report` is for.734735The gate runs alone (`go test ./...` skips `TestSpeed` without736`-speed`), after the rest of `make check`, with the tests that time737frames against fixed bounds, which `make test` leaves to it738([Checking a change](testing.md#checking-a-change)). While it times it holds `/tmp/012-bench.lock`, the lock739other timed runs on the machine take: with flock(2) when the path is a740file (`flock /tmp/012-bench.lock make stress` where the `flock` command741exists), and otherwise as a directory it makes and removes, which needs742no `flock` command. It waits up to ten minutes for a directory someone743else holds.744745When it fails, run `make speed` again on a quiet machine, then look at746the case with the stress benchmarks (`BENCH='Keystroke|Edit' make747stress`). A slowdown that is intended, or a new case, is taken into the748baseline with `make speed-update`, which measures every case and749rewrites `speed.json` for the machine it runs on; commit it with the750change that explains it.