test(bench): add cold and parallel variants for ReadObject, ObjectHeader and Walk
The existing benchmarks read one object over and over, which a cache answers from its first hit. The new ones use WithDeltaCacheSize(0), rotate over up to 8192 objects or starts, and each has a serial and a RunParallel variant for server traffic.
- commit
- 3b41cc5f161e955e3b462b3bdcbc090f7629c674
- author
- brice · · 2026-10-11 00:18 UTC
- parent
- bcb6fbd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +209 -0
A bench_cold_test.go +209 -0
| @@ -0,0 +1,209 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package git | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The benchmarks of bench_test.go read one object over and over, which a cache | |
| 12 | + | // answers from its first hit. These are the cold ones. Each has a serial and | |
| 13 | + | // a parallel variant (b.RunParallel, GOMAXPROCS goroutines, each starting at | |
| 14 | + | // another object) since a server reads from many requests at once: | |
| 15 | + | // | |
| 16 | + | // - "nocache": WithDeltaCacheSize(0), so a deltified object is rebuilt from | |
| 17 | + | // its base each time, with the cost of inflating the base; | |
| 18 | + | // - "rotating": a different object each time, from a set of up to 8192. | |
| 19 | + | ||
| 20 | + | // coldSet returns up to n objects of the repository of the fixture, spread | |
| 21 | + | // over its packs, with their kinds: the first return holds the deltified | |
| 22 | + | // ones, the second the others. | |
| 23 | + | func coldSet(tb testing.TB, r *Repo, n int) (deltified, plain []ObjectID) { | |
| 24 | + | tb.Helper() | |
| 25 | + | s := r.snap.Load() | |
| 26 | + | for _, p := range s.packs { | |
| 27 | + | step := max(1, int(p.idx.n)/(2*n)) | |
| 28 | + | for i := uint32(0); i < p.idx.n && (len(deltified) < n || len(plain) < n); i += uint32(step) { | |
| 29 | + | off, err := p.idx.offset(i) | |
| 30 | + | if err != nil { | |
| 31 | + | tb.Fatal(err) | |
| 32 | + | } | |
| 33 | + | h, err := parseEntryHeader(p.data, off) | |
| 34 | + | if err != nil { | |
| 35 | + | tb.Fatal(err) | |
| 36 | + | } | |
| 37 | + | var id ObjectID | |
| 38 | + | copy(id[:], p.idx.id(i)) | |
| 39 | + | switch { | |
| 40 | + | case h.isDelta() && len(deltified) < n: | |
| 41 | + | deltified = append(deltified, id) | |
| 42 | + | case !h.isDelta() && len(plain) < n: | |
| 43 | + | plain = append(plain, id) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if len(deltified) == 0 { | |
| 48 | + | tb.Fatal("no deltified object in the packs") | |
| 49 | + | } | |
| 50 | + | return deltified, plain | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // reopen opens the repository of the fixture again, without the work of | |
| 54 | + | // the timer. | |
| 55 | + | func reopen(b *testing.B, fx *benchFixture, opts ...Option) *Repo { | |
| 56 | + | b.Helper() | |
| 57 | + | dir := fx.r.Dir() | |
| 58 | + | r, err := Open(dir, opts...) | |
| 59 | + | if err != nil { | |
| 60 | + | b.Fatal(err) | |
| 61 | + | } | |
| 62 | + | return r | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // runSerialAndParallel runs the step returned by setup b.N times in one | |
| 66 | + | // goroutine ("serial"), then b.N times spread over RunParallel's goroutines | |
| 67 | + | // ("parallel", one setup per goroutine). mk opens what the steps use. | |
| 68 | + | func runSerialAndParallel(b *testing.B, mk func(b *testing.B) (setup func() func(i int) error, closeFn func())) { | |
| 69 | + | b.Run("serial", func(b *testing.B) { | |
| 70 | + | setup, closeFn := mk(b) | |
| 71 | + | defer closeFn() | |
| 72 | + | step := setup() | |
| 73 | + | b.ReportAllocs() | |
| 74 | + | b.ResetTimer() | |
| 75 | + | for i := 0; i < b.N; i++ { | |
| 76 | + | if err := step(i); err != nil { | |
| 77 | + | b.Fatal(err) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | b.Run("parallel", func(b *testing.B) { | |
| 82 | + | setup, closeFn := mk(b) | |
| 83 | + | defer closeFn() | |
| 84 | + | var worker atomic.Int64 | |
| 85 | + | b.ReportAllocs() | |
| 86 | + | b.ResetTimer() | |
| 87 | + | b.RunParallel(func(pb *testing.PB) { | |
| 88 | + | step := setup() | |
| 89 | + | i := int(worker.Add(1)) * 7919 | |
| 90 | + | for pb.Next() { | |
| 91 | + | if err := step(i); err != nil { | |
| 92 | + | b.Error(err) | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | i++ | |
| 96 | + | } | |
| 97 | + | }) | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // BenchmarkReadObjectCold: nocache/<kind> reads one deltified object with no | |
| 102 | + | // delta cache; rotating reads a different object each time (deltified and | |
| 103 | + | // plain, default cache); rotating-nocache the same without the cache. | |
| 104 | + | func BenchmarkReadObjectCold(b *testing.B) { | |
| 105 | + | fx := getBench(b) | |
| 106 | + | delta, plain := coldSet(b, fx.r, 4096) | |
| 107 | + | mixed := append(append([]ObjectID(nil), delta...), plain...) | |
| 108 | + | for _, c := range []struct { | |
| 109 | + | name string | |
| 110 | + | ids []ObjectID | |
| 111 | + | cache bool | |
| 112 | + | }{ | |
| 113 | + | {"nocache/deltified-blob", []ObjectID{fx.deltaBlob}, false}, | |
| 114 | + | {"nocache/deltified-tree", []ObjectID{fx.deltaTree}, false}, | |
| 115 | + | {"rotating-nocache", mixed, false}, | |
| 116 | + | {"rotating", mixed, true}, | |
| 117 | + | } { | |
| 118 | + | b.Run(c.name, func(b *testing.B) { | |
| 119 | + | runSerialAndParallel(b, func(b *testing.B) (func() func(int) error, func()) { | |
| 120 | + | var opts []Option | |
| 121 | + | if !c.cache { | |
| 122 | + | opts = append(opts, WithDeltaCacheSize(0)) | |
| 123 | + | } | |
| 124 | + | r := reopen(b, fx, opts...) | |
| 125 | + | return func() func(int) error { | |
| 126 | + | var buf []byte | |
| 127 | + | return func(i int) error { | |
| 128 | + | _, data, err := r.ReadObject(c.ids[i%len(c.ids)], buf) | |
| 129 | + | buf = data | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | }, func() { _ = r.Close() } | |
| 133 | + | }) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // BenchmarkObjectHeaderCold: first-lap reads the header of a different | |
| 139 | + | // deltified object each time and reopens the Repo (so nothing is cached) after | |
| 140 | + | // every lap over the set: the cold cost alone. warm-rotating reads them again | |
| 141 | + | // and again, as GetTree does for the files of a directory. | |
| 142 | + | func BenchmarkObjectHeaderCold(b *testing.B) { | |
| 143 | + | fx := getBench(b) | |
| 144 | + | delta, _ := coldSet(b, fx.r, 4096) | |
| 145 | + | b.Run("deltified-first-lap", func(b *testing.B) { | |
| 146 | + | b.ReportAllocs() | |
| 147 | + | r := reopen(b, fx) | |
| 148 | + | defer func() { _ = r.Close() }() | |
| 149 | + | for i := 0; i < b.N; i++ { | |
| 150 | + | if i > 0 && i%len(delta) == 0 { | |
| 151 | + | b.StopTimer() | |
| 152 | + | _ = r.Close() | |
| 153 | + | r = reopen(b, fx) | |
| 154 | + | b.StartTimer() | |
| 155 | + | } | |
| 156 | + | if _, _, err := r.ObjectHeader(delta[i%len(delta)]); err != nil { | |
| 157 | + | b.Fatal(err) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | }) | |
| 161 | + | b.Run("deltified-warm-rotating", func(b *testing.B) { | |
| 162 | + | r := reopen(b, fx) | |
| 163 | + | defer func() { _ = r.Close() }() | |
| 164 | + | for _, id := range delta { // warm the cache | |
| 165 | + | if _, _, err := r.ObjectHeader(id); err != nil { | |
| 166 | + | b.Fatal(err) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | runSerialAndParallel(b, func(b *testing.B) (func() func(int) error, func()) { | |
| 170 | + | return func() func(int) error { | |
| 171 | + | return func(i int) error { | |
| 172 | + | _, _, err := r.ObjectHeader(delta[i%len(delta)]) | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | }, func() {} | |
| 176 | + | }) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // BenchmarkWalkCold walks 100 commits from a different start each time, with | |
| 181 | + | // no delta cache: the commits come from the pack, deltified or not, and the | |
| 182 | + | // graph if the repository has one. | |
| 183 | + | func BenchmarkWalkCold(b *testing.B) { | |
| 184 | + | fx := getBench(b) | |
| 185 | + | starts := fx.allCommits | |
| 186 | + | if len(starts) > 4096 { | |
| 187 | + | starts = starts[:4096] | |
| 188 | + | } | |
| 189 | + | for _, shape := range []struct { | |
| 190 | + | name string | |
| 191 | + | idOnly bool | |
| 192 | + | }{{"ids", true}, {"full", false}} { | |
| 193 | + | b.Run(shape.name, func(b *testing.B) { | |
| 194 | + | runSerialAndParallel(b, func(b *testing.B) (func() func(int) error, func()) { | |
| 195 | + | r := reopen(b, fx, WithDeltaCacheSize(0)) | |
| 196 | + | return func() func(int) error { | |
| 197 | + | return func(i int) error { | |
| 198 | + | for _, err := range r.Walk(context.Background(), starts[i%len(starts)], WalkOptions{Limit: 100, IDOnly: shape.idOnly}) { | |
| 199 | + | if err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | }, func() { _ = r.Close() } | |
| 206 | + | }) | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | } | |
| @@ -0,0 +1,209 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package git | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The benchmarks of bench_test.go read one object over and over, which a cache | |||
| 12 | + | // answers from its first hit. These are the cold ones. Each has a serial and | |||
| 13 | + | // a parallel variant (b.RunParallel, GOMAXPROCS goroutines, each starting at | |||
| 14 | + | // another object) since a server reads from many requests at once: | |||
| 15 | + | // | |||
| 16 | + | // - "nocache": WithDeltaCacheSize(0), so a deltified object is rebuilt from | |||
| 17 | + | // its base each time, with the cost of inflating the base; | |||
| 18 | + | // - "rotating": a different object each time, from a set of up to 8192. | |||
| 19 | + | ||||
| 20 | + | // coldSet returns up to n objects of the repository of the fixture, spread | |||
| 21 | + | // over its packs, with their kinds: the first return holds the deltified | |||
| 22 | + | // ones, the second the others. | |||
| 23 | + | func coldSet(tb testing.TB, r *Repo, n int) (deltified, plain []ObjectID) { | |||
| 24 | + | tb.Helper() | |||
| 25 | + | s := r.snap.Load() | |||
| 26 | + | for _, p := range s.packs { | |||
| 27 | + | step := max(1, int(p.idx.n)/(2*n)) | |||
| 28 | + | for i := uint32(0); i < p.idx.n && (len(deltified) < n || len(plain) < n); i += uint32(step) { | |||
| 29 | + | off, err := p.idx.offset(i) | |||
| 30 | + | if err != nil { | |||
| 31 | + | tb.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | h, err := parseEntryHeader(p.data, off) | |||
| 34 | + | if err != nil { | |||
| 35 | + | tb.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | var id ObjectID | |||
| 38 | + | copy(id[:], p.idx.id(i)) | |||
| 39 | + | switch { | |||
| 40 | + | case h.isDelta() && len(deltified) < n: | |||
| 41 | + | deltified = append(deltified, id) | |||
| 42 | + | case !h.isDelta() && len(plain) < n: | |||
| 43 | + | plain = append(plain, id) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | if len(deltified) == 0 { | |||
| 48 | + | tb.Fatal("no deltified object in the packs") | |||
| 49 | + | } | |||
| 50 | + | return deltified, plain | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // reopen opens the repository of the fixture again, without the work of | |||
| 54 | + | // the timer. | |||
| 55 | + | func reopen(b *testing.B, fx *benchFixture, opts ...Option) *Repo { | |||
| 56 | + | b.Helper() | |||
| 57 | + | dir := fx.r.Dir() | |||
| 58 | + | r, err := Open(dir, opts...) | |||
| 59 | + | if err != nil { | |||
| 60 | + | b.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | return r | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // runSerialAndParallel runs the step returned by setup b.N times in one | |||
| 66 | + | // goroutine ("serial"), then b.N times spread over RunParallel's goroutines | |||
| 67 | + | // ("parallel", one setup per goroutine). mk opens what the steps use. | |||
| 68 | + | func runSerialAndParallel(b *testing.B, mk func(b *testing.B) (setup func() func(i int) error, closeFn func())) { | |||
| 69 | + | b.Run("serial", func(b *testing.B) { | |||
| 70 | + | setup, closeFn := mk(b) | |||
| 71 | + | defer closeFn() | |||
| 72 | + | step := setup() | |||
| 73 | + | b.ReportAllocs() | |||
| 74 | + | b.ResetTimer() | |||
| 75 | + | for i := 0; i < b.N; i++ { | |||
| 76 | + | if err := step(i); err != nil { | |||
| 77 | + | b.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | b.Run("parallel", func(b *testing.B) { | |||
| 82 | + | setup, closeFn := mk(b) | |||
| 83 | + | defer closeFn() | |||
| 84 | + | var worker atomic.Int64 | |||
| 85 | + | b.ReportAllocs() | |||
| 86 | + | b.ResetTimer() | |||
| 87 | + | b.RunParallel(func(pb *testing.PB) { | |||
| 88 | + | step := setup() | |||
| 89 | + | i := int(worker.Add(1)) * 7919 | |||
| 90 | + | for pb.Next() { | |||
| 91 | + | if err := step(i); err != nil { | |||
| 92 | + | b.Error(err) | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | i++ | |||
| 96 | + | } | |||
| 97 | + | }) | |||
| 98 | + | }) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // BenchmarkReadObjectCold: nocache/<kind> reads one deltified object with no | |||
| 102 | + | // delta cache; rotating reads a different object each time (deltified and | |||
| 103 | + | // plain, default cache); rotating-nocache the same without the cache. | |||
| 104 | + | func BenchmarkReadObjectCold(b *testing.B) { | |||
| 105 | + | fx := getBench(b) | |||
| 106 | + | delta, plain := coldSet(b, fx.r, 4096) | |||
| 107 | + | mixed := append(append([]ObjectID(nil), delta...), plain...) | |||
| 108 | + | for _, c := range []struct { | |||
| 109 | + | name string | |||
| 110 | + | ids []ObjectID | |||
| 111 | + | cache bool | |||
| 112 | + | }{ | |||
| 113 | + | {"nocache/deltified-blob", []ObjectID{fx.deltaBlob}, false}, | |||
| 114 | + | {"nocache/deltified-tree", []ObjectID{fx.deltaTree}, false}, | |||
| 115 | + | {"rotating-nocache", mixed, false}, | |||
| 116 | + | {"rotating", mixed, true}, | |||
| 117 | + | } { | |||
| 118 | + | b.Run(c.name, func(b *testing.B) { | |||
| 119 | + | runSerialAndParallel(b, func(b *testing.B) (func() func(int) error, func()) { | |||
| 120 | + | var opts []Option | |||
| 121 | + | if !c.cache { | |||
| 122 | + | opts = append(opts, WithDeltaCacheSize(0)) | |||
| 123 | + | } | |||
| 124 | + | r := reopen(b, fx, opts...) | |||
| 125 | + | return func() func(int) error { | |||
| 126 | + | var buf []byte | |||
| 127 | + | return func(i int) error { | |||
| 128 | + | _, data, err := r.ReadObject(c.ids[i%len(c.ids)], buf) | |||
| 129 | + | buf = data | |||
| 130 | + | return err | |||
| 131 | + | } | |||
| 132 | + | }, func() { _ = r.Close() } | |||
| 133 | + | }) | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // BenchmarkObjectHeaderCold: first-lap reads the header of a different | |||
| 139 | + | // deltified object each time and reopens the Repo (so nothing is cached) after | |||
| 140 | + | // every lap over the set: the cold cost alone. warm-rotating reads them again | |||
| 141 | + | // and again, as GetTree does for the files of a directory. | |||
| 142 | + | func BenchmarkObjectHeaderCold(b *testing.B) { | |||
| 143 | + | fx := getBench(b) | |||
| 144 | + | delta, _ := coldSet(b, fx.r, 4096) | |||
| 145 | + | b.Run("deltified-first-lap", func(b *testing.B) { | |||
| 146 | + | b.ReportAllocs() | |||
| 147 | + | r := reopen(b, fx) | |||
| 148 | + | defer func() { _ = r.Close() }() | |||
| 149 | + | for i := 0; i < b.N; i++ { | |||
| 150 | + | if i > 0 && i%len(delta) == 0 { | |||
| 151 | + | b.StopTimer() | |||
| 152 | + | _ = r.Close() | |||
| 153 | + | r = reopen(b, fx) | |||
| 154 | + | b.StartTimer() | |||
| 155 | + | } | |||
| 156 | + | if _, _, err := r.ObjectHeader(delta[i%len(delta)]); err != nil { | |||
| 157 | + | b.Fatal(err) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | }) | |||
| 161 | + | b.Run("deltified-warm-rotating", func(b *testing.B) { | |||
| 162 | + | r := reopen(b, fx) | |||
| 163 | + | defer func() { _ = r.Close() }() | |||
| 164 | + | for _, id := range delta { // warm the cache | |||
| 165 | + | if _, _, err := r.ObjectHeader(id); err != nil { | |||
| 166 | + | b.Fatal(err) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | runSerialAndParallel(b, func(b *testing.B) (func() func(int) error, func()) { | |||
| 170 | + | return func() func(int) error { | |||
| 171 | + | return func(i int) error { | |||
| 172 | + | _, _, err := r.ObjectHeader(delta[i%len(delta)]) | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | }, func() {} | |||
| 176 | + | }) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // BenchmarkWalkCold walks 100 commits from a different start each time, with | |||
| 181 | + | // no delta cache: the commits come from the pack, deltified or not, and the | |||
| 182 | + | // graph if the repository has one. | |||
| 183 | + | func BenchmarkWalkCold(b *testing.B) { | |||
| 184 | + | fx := getBench(b) | |||
| 185 | + | starts := fx.allCommits | |||
| 186 | + | if len(starts) > 4096 { | |||
| 187 | + | starts = starts[:4096] | |||
| 188 | + | } | |||
| 189 | + | for _, shape := range []struct { | |||
| 190 | + | name string | |||
| 191 | + | idOnly bool | |||
| 192 | + | }{{"ids", true}, {"full", false}} { | |||
| 193 | + | b.Run(shape.name, func(b *testing.B) { | |||
| 194 | + | runSerialAndParallel(b, func(b *testing.B) (func() func(int) error, func()) { | |||
| 195 | + | r := reopen(b, fx, WithDeltaCacheSize(0)) | |||
| 196 | + | return func() func(int) error { | |||
| 197 | + | return func(i int) error { | |||
| 198 | + | for _, err := range r.Walk(context.Background(), starts[i%len(starts)], WalkOptions{Limit: 100, IDOnly: shape.idOnly}) { | |||
| 199 | + | if err != nil { | |||
| 200 | + | return err | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | }, func() { _ = r.Close() } | |||
| 206 | + | }) | |||
| 207 | + | }) | |||
| 208 | + | } | |||
| 209 | + | } | |||