internal/stress builds deterministic worst cases through the public API: dense grids, an 8192-row formula chain, fan-in (1000 SUMs over a column), fan-out, running totals, volatile cells, deep nesting, long unicode text, many named ranges, tables for filter and sort, and charts. internal/sheet/stress_test.go (go test -tags stress -bench .) measures build and full recalc, single-edit latency and undo per topology, big undo steps, a full history, sort, filter, find/replace, fill series, native save and open, and heap per cell.
- commit
- c72b1279fc79d83a4f0bb472dc8d4726b3e5501a
- parent
- 7267572b01d98b772a19ac9cdd370bd5bc1926e0
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 252 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 217 | ++++++++++++++++++++++++++ |
@@ -0,0 +1,252 @@
1+//go:build stress2+3+// Stress benchmarks of the engine: build and full recalc, single-edit4+// latency per dependency topology, undo, and whole-sheet operations. They5+// only build with -tags stress (see `make stress` and docs/limits.md).6+package sheet_test7+8+import (9+ "bytes"10+ "fmt"11+ "runtime"12+ "testing"13+14+ "012/internal/sheet"15+ "012/internal/stress"16+)17+18+func BenchmarkBuild(b *testing.B) {19+ for _, sh := range stress.Shapes() {20+ b.Run(sh.Name, func(b *testing.B) {21+ for b.Loop() {22+ sh.Build()23+ }24+ })25+ }26+}27+28+func BenchmarkRecalcAll(b *testing.B) {29+ for _, sh := range stress.Shapes() {30+ b.Run(sh.Name, func(b *testing.B) {31+ s := sh.Build()32+ for b.Loop() {33+ s.RecalcAll()34+ }35+ })36+ }37+}38+39+// BenchmarkEdit is the engine's share of typing one entry: storing it and40+// the incremental recalculation it triggers.41+func BenchmarkEdit(b *testing.B) {42+ for _, sh := range stress.Shapes() {43+ b.Run(sh.Name, func(b *testing.B) {44+ s := sh.Build()45+ inputs := [2]string{sh.Input, sh.Input + "1"}46+ i := 047+ for b.Loop() {48+ if err := s.Set(sh.Edit, inputs[i%2]); err != nil {49+ b.Fatal(err)50+ }51+ i++52+ }53+ })54+ }55+}56+57+// BenchmarkUndo is one undo and one redo of a single-cell edit.58+func BenchmarkUndo(b *testing.B) {59+ for _, sh := range stress.Shapes() {60+ b.Run(sh.Name, func(b *testing.B) {61+ s := sh.Build()62+ if err := s.Set(sh.Edit, sh.Input+"1"); err != nil {63+ b.Fatal(err)64+ }65+ for b.Loop() {66+ s.Undo()67+ s.Redo()68+ }69+ })70+ }71+}72+73+// BenchmarkBigUndo clears a dense 8192x26 block and undoes it: the74+// largest single step a user makes by hand.75+func BenchmarkBigUndo(b *testing.B) {76+ s := stress.Dense(sheet.MaxRows, 26)77+ all := sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 25, Row: sheet.MaxRows - 1})78+ for b.Loop() {79+ s.EraseRange(all)80+ s.Undo()81+ }82+}83+84+// BenchmarkHistoryFull makes MaxUndo whole-column edits, the most the85+// history keeps, and reports the heap they hold.86+func BenchmarkHistoryFull(b *testing.B) {87+ for b.Loop() {88+ s := stress.Dense(sheet.MaxRows, 4)89+ before := heap()90+ for i := range sheet.MaxUndo {91+ col := sheet.NewRect(sheet.Addr{Col: i % 4}, sheet.Addr{Col: i % 4, Row: sheet.MaxRows - 1})92+ if err := s.FillEntry(col, col.From, fmt.Sprint(i)); err != nil {93+ b.Fatal(err)94+ }95+ }96+ b.ReportMetric(float64(heap()-before)/(1<<20), "MB-history")97+ runtime.KeepAlive(s)98+ }99+}100+101+func BenchmarkNested(b *testing.B) {102+ for _, depth := range []int{100, 1000, 10000} {103+ for name, f := range map[string]func(int) string{"paren": stress.Nested, "if": stress.NestedIF} {104+ b.Run(fmt.Sprintf("%s-%d", name, depth), func(b *testing.B) {105+ s := sheet.New()106+ src := f(depth)107+ for b.Loop() {108+ if err := s.Set(sheet.Addr{Col: 1}, src); err != nil {109+ b.Fatal(err)110+ }111+ }112+ })113+ }114+ }115+}116+117+func BenchmarkSort(b *testing.B) {118+ s := stress.Table(sheet.MaxRows-1, 8)119+ r := sheet.NewRect(sheet.Addr{Row: 1}, sheet.Addr{Col: 7, Row: sheet.MaxRows - 1})120+ desc := false121+ for b.Loop() {122+ s.SortRange(r, []sheet.SortKey{{Col: 2, Desc: desc}})123+ desc = !desc124+ }125+}126+127+func BenchmarkFilter(b *testing.B) {128+ s := stress.Table(sheet.MaxRows-1, 8)129+ s.CreateFilter(sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 7, Row: sheet.MaxRows - 1}))130+ b.Run("apply", func(b *testing.B) {131+ arg := [2]string{"5000", "2500"}132+ i := 0133+ for b.Loop() {134+ s.FilterColumn(2, sheet.Criteria{Cond: sheet.Condition{Op: sheet.CondGreater, Arg: arg[i%2]}})135+ s.HiddenRows()136+ i++137+ }138+ })139+ b.Run("values", func(b *testing.B) {140+ for b.Loop() {141+ s.FilterValues(3)142+ }143+ })144+}145+146+func BenchmarkFind(b *testing.B) {147+ s := stress.Dense(sheet.MaxRows, 26)148+ b.Run("find", func(b *testing.B) {149+ for b.Loop() {150+ if _, err := s.Find("99.5", sheet.FindOptions{}); err != nil {151+ b.Fatal(err)152+ }153+ }154+ })155+ b.Run("replace-all", func(b *testing.B) {156+ q := [2]string{".", ","}157+ i := 0158+ for b.Loop() {159+ if _, err := s.ReplaceAll(q[i%2], q[(i+1)%2], sheet.FindOptions{}); err != nil {160+ b.Fatal(err)161+ }162+ i++163+ }164+ })165+}166+167+func BenchmarkFillSeries(b *testing.B) {168+ s := sheet.New()169+ s.Set(sheet.Addr{}, "1")170+ s.Set(sheet.Addr{Row: 1}, "2")171+ src := sheet.NewRect(sheet.Addr{}, sheet.Addr{Row: 1})172+ dst := sheet.NewRect(sheet.Addr{}, sheet.Addr{Row: sheet.MaxRows - 1})173+ for b.Loop() {174+ if _, err := s.FillSeries(src, dst); err != nil {175+ b.Fatal(err)176+ }177+ }178+}179+180+// BenchmarkSave writes the native .012 file and reports its size.181+func BenchmarkSave(b *testing.B) {182+ for _, sh := range saveShapes() {183+ b.Run(sh.name, func(b *testing.B) {184+ s := sh.build()185+ var buf bytes.Buffer186+ for b.Loop() {187+ buf.Reset()188+ if err := s.Write(&buf); err != nil {189+ b.Fatal(err)190+ }191+ }192+ b.ReportMetric(float64(buf.Len())/(1<<20), "MB-file")193+ })194+ }195+}196+197+// BenchmarkOpen reads the native .012 file back.198+func BenchmarkOpen(b *testing.B) {199+ for _, sh := range saveShapes() {200+ b.Run(sh.name, func(b *testing.B) {201+ var buf bytes.Buffer202+ if err := sh.build().Write(&buf); err != nil {203+ b.Fatal(err)204+ }205+ for b.Loop() {206+ if _, err := sheet.Read(bytes.NewReader(buf.Bytes())); err != nil {207+ b.Fatal(err)208+ }209+ }210+ })211+ }212+}213+214+type namedBuild struct {215+ name string216+ build func() *sheet.Sheet217+}218+219+func saveShapes() []namedBuild {220+ return []namedBuild{221+ {"dense-8192x26", func() *sheet.Sheet { return stress.Dense(sheet.MaxRows, 26) }},222+ {"dense-8192x256", func() *sheet.Sheet { return stress.Dense(sheet.MaxRows, sheet.MaxCols) }},223+ {"chain-8192", func() *sheet.Sheet { return stress.Chain(sheet.MaxRows) }},224+ }225+}226+227+// BenchmarkMemory reports the live heap per non-blank cell after a load.228+func BenchmarkMemory(b *testing.B) {229+ for _, sh := range []namedBuild{230+ {"numbers", func() *sheet.Sheet { return stress.Dense(sheet.MaxRows, 64) }},231+ {"formulas", func() *sheet.Sheet { return stress.Chain(sheet.MaxRows) }},232+ {"text-200", func() *sheet.Sheet { return stress.LongText(sheet.MaxRows, 200) }},233+ } {234+ b.Run(sh.name, func(b *testing.B) {235+ for b.Loop() {236+ before := heap()237+ s := sh.build()238+ after := heap()239+ b.ReportMetric(float64(after-before)/float64(s.Len()), "B/cell")240+ runtime.KeepAlive(s)241+ }242+ })243+ }244+}245+246+// heap is the live heap after a full collection.247+func heap() uint64 {248+ runtime.GC()249+ var ms runtime.MemStats250+ runtime.ReadMemStats(&ms)251+ return ms.HeapAlloc252+}@@ -0,0 +1,217 @@
1+// Package stress builds synthetic worst-case sheets for the stress2+// benchmarks (see docs/limits.md and `make stress`). Every generator is3+// deterministic, so runs compare, and builds through the public API the4+// way importers do: Load cell by cell, then one RecalcAll.5+package stress6+7+import (8+ "fmt"9+ "math/rand/v2"10+ "strings"11+12+ "012/internal/sheet"13+)14+15+// Shape is one synthetic sheet: a name for benchmark output, and a16+// builder. Rows and Cols describe the populated area.17+type Shape struct {18+ Name string19+ Build func() *sheet.Sheet20+ // Edit is a cell whose change touches the shape's interesting21+ // topology, for single-edit latency, and the entry to type there.22+ Edit sheet.Addr23+ Input string24+}25+26+func at(col, row int) sheet.Addr { return sheet.Addr{Col: col, Row: row} }27+28+func load(s *sheet.Sheet, a sheet.Addr, input string) {29+ if err := s.Load(a, input, sheet.Format{}, sheet.Style{}); err != nil {30+ panic(fmt.Sprintf("%s %q: %v", a, input, err))31+ }32+}33+34+// Dense fills rows x cols with numbers: the storage and render bound.35+func Dense(rows, cols int) *sheet.Sheet {36+ s := sheet.New()37+ r := rand.New(rand.NewPCG(1, 2))38+ for row := range rows {39+ for col := range cols {40+ load(s, at(col, row), fmt.Sprintf("%.2f", r.Float64()*1000))41+ }42+ }43+ s.RecalcAll()44+ return s45+}46+47+// Chain is A1=1 and A(n)=A(n-1)+1 down n rows: the deepest dependency48+// path, evaluated recursively.49+func Chain(n int) *sheet.Sheet {50+ s := sheet.New()51+ load(s, at(0, 0), "1")52+ for row := 1; row < n; row++ {53+ load(s, at(0, row), fmt.Sprintf("=A%d+1", row))54+ }55+ s.RecalcAll()56+ return s57+}58+59+// FanIn is a column of n numbers and k formulas that each SUM all of60+// it: k range users over the same big range.61+func FanIn(n, k int) *sheet.Sheet {62+ s := sheet.New()63+ for row := range n {64+ load(s, at(0, row), fmt.Sprint(row%100))65+ }66+ for i := range k {67+ load(s, at(1+i/n, i%n), fmt.Sprintf("=SUM(A1:A%d)", n))68+ }69+ s.RecalcAll()70+ return s71+}72+73+// FanOut is one cell, A1, read directly by k formulas.74+func FanOut(k int) *sheet.Sheet {75+ s := sheet.New()76+ load(s, at(0, 0), "1")77+ for i := range k {78+ load(s, at(1+i/sheet.MaxRows, i%sheet.MaxRows), fmt.Sprintf("=$A$1*%d", i))79+ }80+ s.RecalcAll()81+ return s82+}83+84+// RunningTotals is n numbers with a running SUM beside each: n85+// overlapping ranges of growing size, O(n^2) cells read in all.86+func RunningTotals(n int) *sheet.Sheet {87+ s := sheet.New()88+ for row := range n {89+ load(s, at(0, row), fmt.Sprint(row%10))90+ load(s, at(1, row), fmt.Sprintf("=SUM($A$1:A%d)", row+1))91+ }92+ s.RecalcAll()93+ return s94+}95+96+// Volatile is k cells alternating TODAY() and RAND(), recalculated on97+// every change anywhere.98+func Volatile(k int) *sheet.Sheet {99+ s := sheet.New()100+ for i := range k {101+ f := "=RAND()"102+ if i%2 == 1 {103+ f = "=TODAY()"104+ }105+ load(s, at(i/sheet.MaxRows, i%sheet.MaxRows), f)106+ }107+ s.RecalcAll()108+ return s109+}110+111+// Nested is a formula nested depth levels deep, e.g. =((((1+1)+1)+1)+1),112+// in A1: the parser's and evaluator's recursion.113+func Nested(depth int) string {114+ return "=" + strings.Repeat("(", depth) + "1" + strings.Repeat("+1)", depth)115+}116+117+// NestedIF is =IF(A1>0,IF(A1>1,...,0),0) depth levels deep.118+func NestedIF(depth int) string {119+ var b strings.Builder120+ b.WriteString("=")121+ for i := range depth {122+ fmt.Fprintf(&b, "IF(A1>%d,", i)123+ }124+ b.WriteString("1")125+ b.WriteString(strings.Repeat(",0)", depth))126+ return b.String()127+}128+129+// LongText puts a length-n line of text, mixing ASCII, accents, CJK and130+// emoji, in column A of rows rows and leaves everything else blank, so131+// every row overflows across the whole screen.132+func LongText(rows, n int) *sheet.Sheet {133+ s := sheet.New()134+ const alphabet = "The quick brown fox jumps over the lazy dog. Ça déjà vu, naïve café. 東京都の天気は晴れ。 🙂🚀 "135+ src := []rune(alphabet)136+ for row := range rows {137+ var b strings.Builder138+ for i := range n {139+ b.WriteRune(src[(i+row)%len(src)])140+ }141+ load(s, at(0, row), b.String())142+ }143+ s.RecalcAll()144+ return s145+}146+147+// Names defines k named ranges over a column of 8192 numbers and a148+// formula using each: every changed cell is checked against every name.149+func Names(k int) *sheet.Sheet {150+ s := sheet.New()151+ for row := range sheet.MaxRows {152+ load(s, at(0, row), fmt.Sprint(row%100))153+ }154+ for i := range k {155+ from := (i * 7) % (sheet.MaxRows - 100)156+ name := fmt.Sprintf("Region_%d", i)157+ if err := s.DefineName(name, sheet.NewRect(at(0, from), at(0, from+99))); err != nil {158+ panic(err)159+ }160+ load(s, at(1+i/sheet.MaxRows, i%sheet.MaxRows), "=SUM("+name+")")161+ }162+ s.RecalcAll()163+ return s164+}165+166+// Table is a filterable, sortable table: a header row and rows rows of167+// an id, a category, a number and a date-like text, in cols columns.168+func Table(rows, cols int) *sheet.Sheet {169+ s := sheet.New()170+ r := rand.New(rand.NewPCG(3, 4))171+ cats := []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"}172+ for col := range cols {173+ load(s, at(col, 0), fmt.Sprintf("H%d", col))174+ }175+ for row := 1; row <= rows; row++ {176+ for col := range cols {177+ var v string178+ switch col % 4 {179+ case 0:180+ v = fmt.Sprint(row)181+ case 1:182+ v = cats[r.IntN(len(cats))]183+ case 2:184+ v = fmt.Sprintf("%.2f", r.Float64()*1e4)185+ default:186+ v = fmt.Sprintf("item %d", r.IntN(1000))187+ }188+ load(s, at(col, row), v)189+ }190+ }191+ s.RecalcAll()192+ return s193+}194+195+// Charts adds k small column charts over a data block to s.196+func Charts(s *sheet.Sheet, k int) {197+ for i := range k {198+ s.AddChart(sheet.Chart{199+ Type: sheet.ChartColumn, Data: sheet.NewRect(at(0, 0), at(2, 20)), Header: true, Labels: true,200+ At: at(4+(i%8)*3, (i/8)*3), W: 24, H: 10,201+ })202+ }203+ s.ClearHistory()204+}205+206+// Shapes are the edit-latency topologies at their stress sizes.207+func Shapes() []Shape {208+ return []Shape{209+ {"dense-8192x26", func() *sheet.Sheet { return Dense(sheet.MaxRows, 26) }, at(0, 0), "5"},210+ {"chain-8192", func() *sheet.Sheet { return Chain(sheet.MaxRows) }, at(0, 0), "2"},211+ {"fanin-1000xSUM8192", func() *sheet.Sheet { return FanIn(sheet.MaxRows, 1000) }, at(0, 4000), "7"},212+ {"fanout-8192", func() *sheet.Sheet { return FanOut(sheet.MaxRows) }, at(0, 0), "2"},213+ {"running-8192", func() *sheet.Sheet { return RunningTotals(sheet.MaxRows) }, at(0, 0), "3"},214+ {"volatile-8192", func() *sheet.Sheet { return Volatile(sheet.MaxRows) }, at(200, 0), "1"},215+ {"names-1000", func() *sheet.Sheet { return Names(1000) }, at(0, 50), "9"},216+ }217+}