detached 3b41cc5

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close