cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Find range users by column, and skip propagation in RecalcAll

Dami committed 66fabf0
Every changed cell scanned every formula with a range reference, so a
change cost O(changed x range users): 8192 running totals checked 67
million ranges per edit. Range users are now indexed by the columns
their ranges cover. Formulas are queued once rather than once per cell
they read, named ranges are looked up once per recalc, and the
evaluator looks each cell's state up once.

RecalcAll marks every cell dirty directly: tracing dependents from
every cell found nothing new and cost O(cells x range users).

  Edit/running-8192          1.83 s -> 0.70 s
  Edit/fanin-1000xSUM8192    203 ms -> 168 ms
  Edit/names-1000            165 us -> 94 us
  RecalcAll/fanin            395 ms -> 172 ms
  RecalcAll/names-1000       157 ms -> 2.8 ms
  RecalcAll/dense-8192x26     69 ms -> 38 ms
  Build/running-8192         3.98 s -> 0.72 s
commit
66fabf0336eefd39d11fc6cbfaa2fd5ba7c06856
parent
1a9be26d5cb914f9973659f415bcf8b7a79e6d2c
author
Dami <dami.etoile@gmail.com>
4 files changed, +155 -28
Ainternal/sheet/rangeindex.go41++++++++++++++
Ainternal/sheet/rangeindex_test.go51+++++++++++++++++
Minternal/sheet/sheet.go89+++++++++++++++++++++---------
Minternal/sheet/trace.go2+-
A internal/sheet/rangeindex.go +41 -0
A internal/sheet/rangeindex_test.go +51 -0
@@ -0,0 +1,51 @@
1+package sheet2+3+import "testing"4+5+// Range users are found by column; a formula must follow a change in any6+// column its ranges cover, and stop following a range it no longer has.7+func TestRangeUsersByColumn(t *testing.T) {8+ tests := []struct {9+ name string10+ setup []string // cell=input pairs, in order11+ change string // cell=input12+ cell string13+ want float6414+ }{15+ {"middle column of a block", []string{"A1=1", "B2=2", "C3=3", "E1==SUM(A1:C3)"}, "B3=10", "E1", 16},16+ {"two ranges", []string{"A1=1", "D1=4", "E1==SUM(A1:A2,D1:D2)"}, "D2=5", "E1", 10},17+ {"range replaced by another", []string{"A1=1", "B1=2", "E1==SUM(A1:A5)", "E1==SUM(B1:B5)"}, "B2=3", "E1", 5},18+ {"through a chain", []string{"A1=1", "B1==SUM(A1:A3)", "C1==B1*2"}, "A3=4", "C1", 10},19+ {"row range", []string{"A1=1", "E2==SUM(A1:D1)"}, "D1=2", "E2", 3},20+ }21+ for _, tt := range tests {22+ t.Run(tt.name, func(t *testing.T) {23+ s := New()24+ set := func(pair string) {25+ t.Helper()26+ for i := range pair {27+ if pair[i] == '=' {28+ if err := s.Set(at(pair[:i]), pair[i+1:]); err != nil {29+ t.Fatal(err)30+ }31+ return32+ }33+ }34+ }35+ for _, p := range tt.setup {36+ set(p)37+ }38+ set(tt.change)39+ if got := s.Value(at(tt.cell)); got.Num != tt.want {40+ t.Errorf("%s = %v, want %v", tt.cell, got, tt.want)41+ }42+ })43+ }44+ // Replacing E1's formula leaves nothing indexed under column A.45+ s := New()46+ s.Set(at("E1"), "=SUM(A1:A5)")47+ s.Set(at("E1"), "=SUM(B1:B5)")48+ if n := len(s.rangeUsers.candidates(0)); n != 0 {49+ t.Errorf("%d stale range users in column A", n)50+ }51+}
M internal/sheet/sheet.go +62 -27
@@ -52,9 +52,10 @@ type Sheet struct {
5252 5353 // dependents maps a cell to the formula cells that reference it5454 // directly. Range references are kept on the formula cell itself and55- // scanned separately, so a huge range doesn't create millions of edges.55+ // found through rangeUsers, by column, so a huge range doesn't create56+ // millions of edges.5657 dependents map[Addr]map[Addr]struct{}57- rangeUsers map[Addr]struct{}58+ rangeUsers rangeIndex5859 volatile map[Addr]struct{} // formulas recalculated on every change5960 6061 // names are the named ranges by upper-case name (see names.go), and
@@ -80,7 +81,6 @@ func New() *Sheet {
8081 cells: make(map[Addr]*Cell),8182 widths: make(map[int]int),8283 dependents: make(map[Addr]map[Addr]struct{}),83- rangeUsers: make(map[Addr]struct{}),8484 volatile: make(map[Addr]struct{}),8585 names: make(map[string]Name),8686 nameUsers: make(map[string]map[Addr]struct{}),
@@ -292,9 +292,7 @@ func (s *Sheet) place(a Addr, c *Cell) {
292292 }293293 s.dependents[r][a] = struct{}{}294294 }295- if len(c.ranges) > 0 {296- s.rangeUsers[a] = struct{}{}297- }295+ s.rangeUsers.add(a, c.ranges)298296 if c.volatile {299297 s.volatile[a] = struct{}{}300298 }
@@ -347,7 +345,7 @@ func (s *Sheet) unlink(a Addr) {
347345 delete(s.nameUsers, k)348346 }349347 }350- delete(s.rangeUsers, a)348+ s.rangeUsers.remove(a, old.ranges)351349 delete(s.volatile, a)352350 delete(s.cells, a)353351 }
@@ -355,26 +353,54 @@ func (s *Sheet) unlink(a Addr) {
355353 // RecalcAll recomputes every formula. Loaders call it once the sheet is356354 // built, so it also starts a fresh undo history: loading isn't undoable.357355 func (s *Sheet) RecalcAll() {358- s.recalc(s.Addrs())356+ // Every cell is dirty, so there is nothing to propagate: tracing357+ // dependents from each cell cost O(cells x range users).358+ state := make(map[Addr]int, len(s.cells))359+ for a := range s.cells {360+ state[a] = dirty361+ }362+ s.evaluate(state)359363 s.ClearHistory()360364 }361365 366+// Recalculation states of a cell.367+const (368+ dirty = iota + 1369+ visiting370+ done371+)372+362373 // recalc recomputes the changed cells, volatile formulas, and everything363-// that transitively depends on them. Cells are evaluated lazily in364-// dependency order: reading a dirty cell evaluates it first. A cell that365-// is reached again while it is still being evaluated is part of a cycle366-// and becomes ERR.374+// that transitively depends on them.367375 func (s *Sheet) recalc(changed []Addr) {368- const (369- dirty = iota + 1370- visiting371- done372- )376+ s.evaluate(s.affected(changed))377+}378+379+// affected marks dirty the changed cells, volatile formulas, and every380+// formula that transitively reads them.381+func (s *Sheet) affected(changed []Addr) map[Addr]int {373382 state := make(map[Addr]int)374383 queue := append([]Addr(nil), changed...)375384 for a := range s.volatile {376385 queue = append(queue, a)377386 }387+ // The named ranges in use, looked up once rather than for every cell.388+ type namedUsers struct {389+ r Rect390+ users map[Addr]struct{}391+ }392+ var named []namedUsers393+ for k, users := range s.nameUsers {394+ if nm, ok := s.names[k]; ok && !nm.Lost {395+ named = append(named, namedUsers{nm.Range, users})396+ }397+ }398+ // Queue each formula once, not once per changed cell it reads.399+ push := func(u Addr) {400+ if state[u] != dirty {401+ queue = append(queue, u)402+ }403+ }378404 for len(queue) > 0 {379405 a := queue[0]380406 queue = queue[1:]
@@ -383,37 +409,46 @@ func (s *Sheet) recalc(changed []Addr) {
383409 }384410 state[a] = dirty385411 for d := range s.dependents[a] {386- queue = append(queue, d)412+ push(d)387413 }388- for u := range s.rangeUsers {414+ for u := range s.rangeUsers.candidates(a.Col) {389415 for _, r := range s.cells[u].ranges {390416 if r.Contains(a) {391- queue = append(queue, u)417+ push(u)392418 break393419 }394420 }395421 }396- for k, users := range s.nameUsers {397- if nm, ok := s.names[k]; ok && !nm.Lost && nm.Range.Contains(a) {398- for u := range users {399- queue = append(queue, u)422+ for _, n := range named {423+ if n.r.Contains(a) {424+ for u := range n.users {425+ push(u)400426 }401427 }402428 }403429 }430+ return state431+}404432 433+// evaluate computes the cells marked dirty in state. Cells are evaluated434+// lazily in dependency order: reading a dirty cell evaluates it first. A435+// cell that is reached again while it is still being evaluated is part of436+// a cycle and becomes ERR.437+func (s *Sheet) evaluate(state map[Addr]int) {405438 s.Circular = false406439 s.hidden.valid = false // values may have changed what the filter hides407440 var compute func(Addr) Value408441 compute = func(a Addr) Value {442+ // Every cell a formula reads comes through here, so it looks443+ // each map up once: a SUM over 8192 cells makes 8192 calls.409444 c := s.cells[a]410- switch {445+ switch st := state[a]; {411446 case c == nil:412447 return Value{}413- case state[a] == visiting:448+ case st == visiting:414449 s.Circular = true415450 return ErrRef416- case state[a] != dirty:451+ case st != dirty:417452 return c.Value418453 }419454 state[a] = visiting
M internal/sheet/trace.go +1 -1