v0.1.1 de06cbd

bench_test.go RawHistoryFolder

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}

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