1// SPDX-License-Identifier: MIT
2
3package git
4
5import (
6 "bufio"
7 "bytes"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "strconv"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17)
18
19// benchFixture is a repository with loose objects, a deltified pack and a
20// directory of many entries. With ERMITE_GIT_BENCH_REPO it is that
21// repository instead.
22type benchFixture struct {
23 dir string
24 r *Repo
25 looseCommit, looseBlob ObjectID
26 packedCommit, packedBlob ObjectID // not deltified
27 deltaBlob, deltaTree ObjectID // deltified in the pack
28 bigTree, headCommit ObjectID
29 prefix string
30 batchIDs []ObjectID // objects for the git cat-file comparison
31 allCommits []ObjectID
32}
33
34var (
35 benchOnce sync.Once
36 benchFx *benchFixture
37)
38
39func getBench(b *testing.B) *benchFixture {
40 b.Helper()
41 requireGit(b)
42 benchOnce.Do(func() {
43 fx := &benchFixture{}
44 if dir := os.Getenv("ERMITE_GIT_BENCH_REPO"); dir != "" {
45 fx.dir = dir
46 fx.fromRepo(b, dir)
47 } else {
48 fx.generate(b)
49 }
50 benchFx = fx
51 })
52 if benchFx == nil {
53 b.Fatal("benchmark fixture failed")
54 }
55 return benchFx
56}
57
58func (fx *benchFixture) fromRepo(b *testing.B, dir string) {
59 r, err := Open(dir)
60 if err != nil {
61 b.Fatal(err)
62 }
63 fx.r = r
64 out := runGit(b, dir, nil, nil, "rev-list", "--all")
65 for _, l := range lines(string(out)) {
66 fx.allCommits = append(fx.allCommits, mustID(b, l))
67 }
68 fx.headCommit = fx.allCommits[0]
69 fx.finish(b, dir)
70}
71
72func (fx *benchFixture) generate(b *testing.B) {
73 dir, err := os.MkdirTemp("", "ermite-git-bench-*")
74 if err != nil {
75 b.Fatal(err)
76 }
77 fx.dir = dir
78 env := append(gitEnv(), "GIT_AUTHOR_DATE=1700000000 +0000", "GIT_COMMITTER_DATE=1700000000 +0000")
79 run := func(args ...string) string {
80 cmd := exec.Command("git", append([]string{"-c", "gc.auto=0", "-c", "init.defaultBranch=main", "-c", "commit.gpgsign=false"}, args...)...)
81 cmd.Dir, cmd.Env = dir, env
82 out, err := cmd.CombinedOutput()
83 if err != nil {
84 b.Fatalf("git %v: %v\n%s", args, err, out)
85 }
86 return strings.TrimSpace(string(out))
87 }
88 run("init", "-q")
89 for i := 0; i < 400; i++ {
90 _ = os.WriteFile(dir+"/f.txt", append(textLines(800, 0), []byte(fmt.Sprintf("rev %d\n", i))...), 0o644)
91 _ = os.MkdirAll(dir+"/many", 0o755)
92 _ = os.WriteFile(fmt.Sprintf("%s/many/file-%04d.txt", dir, i), []byte(fmt.Sprint(i)), 0o644)
93 run("add", "-A")
94 run("commit", "-q", "-m", fmt.Sprintf("commit %d\n\nbody of the message", i))
95 }
96 // Some commits stay loose: one more after the repack.
97 run("repack", "-adf", "--depth=50", "--window=100")
98 _ = os.WriteFile(dir+"/loose.txt", textLines(300, 5), 0o644)
99 run("add", "-A")
100 run("commit", "-q", "-m", "loose commit")
101 var err2 error
102 fx.r, err2 = Open(dir + "/.git")
103 if err2 != nil {
104 b.Fatal(err2)
105 }
106 fx.headCommit = mustID(b, run("rev-parse", "HEAD"))
107 fx.looseCommit = fx.headCommit
108 fx.looseBlob = mustID(b, run("rev-parse", "HEAD:loose.txt"))
109 for _, l := range lines(run("rev-list", "--all")) {
110 fx.allCommits = append(fx.allCommits, mustID(b, l))
111 }
112 fx.bigTree = mustID(b, run("rev-parse", "HEAD:many"))
113 fx.finish(b, dir+"/.git")
114}
115
116// finish picks one object of each kind from the pack.
117func (fx *benchFixture) finish(b *testing.B, gitDir string) {
118 s := fx.r.snap.Load()
119 for _, p := range s.packs {
120 for i := uint32(0); i < p.idx.n && (fx.deltaBlob.IsZero() || fx.packedBlob.IsZero() || fx.deltaTree.IsZero() || fx.packedCommit.IsZero()); i++ {
121 off, _ := p.idx.offset(i)
122 h, err := parseEntryHeader(p.data, off)
123 if err != nil {
124 b.Fatal(err)
125 }
126 var id ObjectID
127 copy(id[:], p.idx.id(i))
128 switch {
129 case h.isDelta():
130 // The size of a delta is that of the delta, not of the
131 // object: do not filter on it.
132 typ, err := p.chainEnd(off, h)
133 if err != nil {
134 b.Fatal(err)
135 }
136 if typ == TypeBlob && fx.deltaBlob.IsZero() {
137 fx.deltaBlob = id
138 }
139 if typ == TypeTree && fx.deltaTree.IsZero() {
140 fx.deltaTree = id
141 }
142 case h.typ == uint8(TypeBlob) && h.size > 200 && fx.packedBlob.IsZero():
143 fx.packedBlob = id
144 case h.typ == uint8(TypeCommit) && fx.packedCommit.IsZero():
145 fx.packedCommit = id
146 }
147 }
148 }
149 if fx.looseCommit.IsZero() {
150 fx.looseCommit = fx.packedCommit
151 }
152 if fx.looseBlob.IsZero() {
153 fx.looseBlob = fx.packedBlob
154 }
155 if fx.deltaBlob.IsZero() || fx.deltaTree.IsZero() {
156 b.Fatalf("no deltified blob (%v) or tree (%v) in the packs of %s: the deltified benchmarks would measure something else",
157 !fx.deltaBlob.IsZero(), !fx.deltaTree.IsZero(), gitDir)
158 }
159 if fx.bigTree.IsZero() {
160 var c Commit
161 if err := fx.r.ReadCommit(fx.headCommit, &c); err != nil {
162 b.Fatal(err)
163 }
164 fx.bigTree = c.Tree
165 }
166 fx.prefix = fx.headCommit.String()[:7]
167 // The set for the cat-file comparison: the commits, then their trees.
168 var c Commit
169 for i, id := range fx.allCommits {
170 if i >= 2000 {
171 break
172 }
173 fx.batchIDs = append(fx.batchIDs, id)
174 if err := fx.r.ReadCommit(id, &c); err == nil {
175 fx.batchIDs = append(fx.batchIDs, c.Tree)
176 }
177 }
178}
179
180func BenchmarkReadObject(b *testing.B) {
181 fx := getBench(b)
182 for _, c := range []struct {
183 name string
184 id ObjectID
185 }{
186 {"loose-commit", fx.looseCommit},
187 {"loose-blob", fx.looseBlob},
188 {"packed-commit", fx.packedCommit},
189 {"packed-blob", fx.packedBlob},
190 {"deltified-blob", fx.deltaBlob},
191 {"deltified-tree", fx.deltaTree},
192 } {
193 b.Run(c.name, func(b *testing.B) {
194 var buf []byte
195 b.ReportAllocs()
196 for b.Loop() {
197 _, data, err := fx.r.ReadObject(c.id, buf)
198 if err != nil {
199 b.Fatal(err)
200 }
201 buf = data
202 }
203 b.SetBytes(int64(len(buf)))
204 })
205 }
206}
207
208func BenchmarkReadObjectParallel(b *testing.B) {
209 fx := getBench(b)
210 b.ReportAllocs()
211 b.RunParallel(func(pb *testing.PB) {
212 var buf []byte
213 i := 0
214 for pb.Next() {
215 id := fx.batchIDs[i%len(fx.batchIDs)]
216 i++
217 _, data, err := fx.r.ReadObject(id, buf)
218 if err != nil {
219 b.Fatal(err)
220 }
221 buf = data
222 }
223 })
224}
225
226func BenchmarkObjectHeader(b *testing.B) {
227 fx := getBench(b)
228 for _, c := range []struct {
229 name string
230 id ObjectID
231 }{{"loose", fx.looseBlob}, {"packed", fx.packedBlob}, {"deltified", fx.deltaBlob}} {
232 b.Run(c.name, func(b *testing.B) {
233 b.ReportAllocs()
234 for b.Loop() {
235 if _, _, err := fx.r.ObjectHeader(c.id); err != nil {
236 b.Fatal(err)
237 }
238 }
239 })
240 }
241}
242
243func BenchmarkReadCommit(b *testing.B) {
244 fx := getBench(b)
245 for _, c := range []struct {
246 name string
247 id ObjectID
248 }{{"loose", fx.looseCommit}, {"packed", fx.packedCommit}} {
249 b.Run(c.name, func(b *testing.B) {
250 var commit Commit
251 if err := fx.r.ReadCommit(c.id, &commit); err != nil { // warm-up
252 b.Fatal(err)
253 }
254 b.ReportAllocs()
255 for b.Loop() {
256 if err := fx.r.ReadCommit(c.id, &commit); err != nil {
257 b.Fatal(err)
258 }
259 }
260 })
261 }
262}
263
264func BenchmarkParseCommit(b *testing.B) {
265 raw := []byte("tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nauthor Ada Lovelace <ada@example.com> 1700000000 +0100\ncommitter Grace Hopper <grace@example.com> 1700000001 -0800\n\nmessage\n")
266 var c Commit
267 b.ReportAllocs()
268 b.SetBytes(int64(len(raw)))
269 for b.Loop() {
270 if err := ParseCommit(raw, &c); err != nil {
271 b.Fatal(err)
272 }
273 }
274}
275
276func BenchmarkTreeIteration(b *testing.B) {
277 fx := getBench(b)
278 for _, c := range []struct {
279 name string
280 id ObjectID
281 }{{"many-entries", fx.bigTree}, {"deltified", fx.deltaTree}} {
282 b.Run(c.name, func(b *testing.B) {
283 entries := 0
284 var sum int
285 cb := func(e Entry) bool {
286 entries++
287 sum += len(e.Name) + int(e.Mode)
288 return true
289 }
290 if err := fx.r.ReadTree(c.id, cb); err != nil { // warm-up
291 b.Fatal(err)
292 }
293 per := entries
294 b.ReportAllocs()
295 entries = 0
296 for b.Loop() {
297 if err := fx.r.ReadTree(c.id, cb); err != nil {
298 b.Fatal(err)
299 }
300 }
301 _ = sum
302 b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(max(entries, 1)), "ns/entry")
303 b.ReportMetric(float64(per), "entries/tree")
304 })
305 }
306}
307
308func BenchmarkParseTree(b *testing.B) {
309 var tree []byte
310 for i := 0; i < 500; i++ {
311 tree = append(tree, fmt.Sprintf("100644 file-%04d.txt\x00", i)...)
312 tree = append(tree, bytes.Repeat([]byte{byte(i)}, 20)...)
313 }
314 n := 0
315 cb := func(e Entry) bool { n += len(e.Name); return true }
316 b.ReportAllocs()
317 b.SetBytes(int64(len(tree)))
318 for b.Loop() {
319 if err := ParseTree(tree, cb); err != nil {
320 b.Fatal(err)
321 }
322 }
323}
324
325func BenchmarkResolvePrefix(b *testing.B) {
326 fx := getBench(b)
327 for _, n := range []int{7, 12} {
328 b.Run(strconv.Itoa(n)+"-digits", func(b *testing.B) {
329 ids := make([]string, 0, 64)
330 for i := 0; i < len(fx.allCommits) && i < 64; i++ {
331 ids = append(ids, fx.allCommits[i].String()[:n])
332 }
333 b.ReportAllocs()
334 i := 0
335 for b.Loop() {
336 if _, err := fx.r.ResolvePrefix(ids[i%len(ids)]); err != nil {
337 b.Fatal(err)
338 }
339 i++
340 }
341 })
342 }
343 b.Run("missing", func(b *testing.B) {
344 b.ReportAllocs()
345 for b.Loop() {
346 if _, err := fx.r.ResolvePrefix("0000000"); err == nil {
347 b.Fatal("found")
348 }
349 }
350 })
351}
352
353func BenchmarkRefs(b *testing.B) {
354 fx := getBench(b)
355 b.Run("all", func(b *testing.B) {
356 b.ReportAllocs()
357 for b.Loop() {
358 n := 0
359 for _, err := range fx.r.Refs("") {
360 if err != nil {
361 b.Fatal(err)
362 }
363 n++
364 }
365 }
366 })
367 b.Run("resolve-head", func(b *testing.B) {
368 b.ReportAllocs()
369 for b.Loop() {
370 if _, err := fx.r.ResolveRef("HEAD"); err != nil {
371 b.Fatal(err)
372 }
373 }
374 })
375 b.Run("head", func(b *testing.B) {
376 b.ReportAllocs()
377 for b.Loop() {
378 if _, _, err := fx.r.Head(); err != nil {
379 b.Fatal(err)
380 }
381 }
382 })
383 b.Run("resolve-short-name", func(b *testing.B) {
384 b.ReportAllocs()
385 for b.Loop() {
386 if _, err := fx.r.Resolve("main"); err != nil {
387 _, _ = fx.r.Resolve("master")
388 }
389 }
390 })
391}
392
393func BenchmarkOpenBlob(b *testing.B) {
394 fx := getBench(b)
395 for _, c := range []struct {
396 name string
397 id ObjectID
398 }{{"loose", fx.looseBlob}, {"packed", fx.packedBlob}, {"deltified", fx.deltaBlob}} {
399 b.Run(c.name, func(b *testing.B) {
400 b.ReportAllocs()
401 for b.Loop() {
402 size, rc, err := fx.r.OpenBlob(c.id)
403 if err != nil {
404 b.Fatal(err)
405 }
406 b.SetBytes(size)
407 if _, err := io.Copy(io.Discard, rc); err != nil {
408 b.Fatal(err)
409 }
410 _ = rc.Close()
411 }
412 })
413 }
414}
415
416// BenchmarkRefresh is Refresh on a repository that did not change, the case
417// of every Refresh but the one after a push. The generated fixture was written
418// a moment ago, inside the racy window of its stat data, so its files are
419// aged first and one Refresh records that they are old.
420func BenchmarkRefresh(b *testing.B) {
421 fx := getBench(b)
422 if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" {
423 old := time.Now().Add(-time.Hour)
424 for _, p := range []string{"packed-refs", "objects/pack", "objects/info/commit-graph", "objects/info/commit-graphs/commit-graph-chain"} {
425 _ = os.Chtimes(fx.r.Dir()+"/"+p, old, old)
426 }
427 for range 3 {
428 if err := fx.r.Refresh(); err != nil {
429 b.Fatal(err)
430 }
431 }
432 }
433 b.ReportAllocs()
434 for b.Loop() {
435 if err := fx.r.Refresh(); err != nil {
436 b.Fatal(err)
437 }
438 }
439}
440
441// BenchmarkGitCatFileBatch is the informal reference: the same objects read
442// through one long-lived "git cat-file --batch" process, as the gitcli adapter
443// does. Compare with BenchmarkReadObjectSequence.
444func BenchmarkGitCatFileBatch(b *testing.B) {
445 fx := getBench(b)
446 cmd := exec.Command("git", "cat-file", "--batch")
447 cmd.Dir = fx.dir
448 cmd.Env = gitEnv()
449 stdin, _ := cmd.StdinPipe()
450 stdout, _ := cmd.StdoutPipe()
451 if err := cmd.Start(); err != nil {
452 b.Fatal(err)
453 }
454 defer func() { _ = stdin.Close(); _ = cmd.Wait() }()
455 br := bufio.NewReaderSize(stdout, 1<<20)
456 var buf []byte
457 req := make([]byte, 0, 64)
458 b.ReportAllocs()
459 i, total := 0, int64(0)
460 for b.Loop() {
461 id := fx.batchIDs[i%len(fx.batchIDs)]
462 i++
463 req = append(id.AppendHex(req[:0]), '\n')
464 if _, err := stdin.Write(req); err != nil {
465 b.Fatal(err)
466 }
467 hdr, err := br.ReadSlice('\n')
468 if err != nil {
469 b.Fatal(err)
470 }
471 f := bytes.Fields(hdr)
472 size, _ := strconv.Atoi(string(f[2]))
473 buf = grow(buf, size+1)
474 if _, err := io.ReadFull(br, buf); err != nil {
475 b.Fatal(err)
476 }
477 total += int64(size)
478 }
479 b.SetBytes(total / int64(max(b.N, 1)))
480}
481
482func BenchmarkReadObjectSequence(b *testing.B) {
483 fx := getBench(b)
484 var buf []byte
485 b.ReportAllocs()
486 i, total := 0, int64(0)
487 for b.Loop() {
488 id := fx.batchIDs[i%len(fx.batchIDs)]
489 i++
490 _, data, err := fx.r.ReadObject(id, buf)
491 if err != nil {
492 b.Fatal(err)
493 }
494 buf = data
495 total += int64(len(data))
496 }
497 b.SetBytes(total / int64(max(b.N, 1)))
498}
499
500func TestMain(m *testing.M) {
501 code := m.Run()
502 cleanupRealWalk()
503 if benchFx != nil && benchFx.r != nil {
504 _ = benchFx.r.Close()
505 if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" {
506 _ = os.RemoveAll(benchFx.dir)
507 }
508 }
509 os.Exit(code)
510}