detached 3da52c2

perf(pack): cache the result size of deltified objects per pack (GO-004)

ObjectHeader on a deltified object inflates the start of the delta. headerCache keeps (type, size) by entry offset in a fixed 2-way table sized by object count (64 to 65,536 slots, at most 1.5 MiB per pack, allocated at the first deltified header), built from atomic seqlock slots: no lock, no allocation on a hit, no invalidation since packs are immutable.
commit
3da52c245cd3290b7a7d1b762332ab82222d39b6
author
brice · · 2026-10-10 23:57 UTC
parent
9ff5364
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +288 -10
A headercache.go +110 -0
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "math/bits"
7+ "sync/atomic"
8+)
9+
10+// headerCache remembers the type and the result size of the deltified
11+// objects of one pack, by entry offset. Finding that size means inflating the
12+// start of the delta, which dominated listing a directory with the size of
13+// each file (ObjectHeader per entry).
14+//
15+// A pack never changes, so an entry never goes stale and nothing invalidates
16+// it; the cache goes away with the pack. It is a fixed array of slots in
17+// which an offset has two places (a 2-way set: the slot its hash picks and
18+// the neighbour), and a newer entry replaces one of the two when both are
19+// taken. The memory is bounded by construction (headerSlotBytes per slot, at
20+// most maxHeaderSlots slots per pack, allocated at the first deltified
21+// header), a hit costs a hash and a few atomic loads and allocates nothing,
22+// and there is no lock, no eviction list and no allocation per entry. One way
23+// only made two hot offsets that collide evict each other on every lookup:
24+// 4,096 deltified headers read in rotation on lazygit (65,536 slots) cost
25+// 334 ns each with one way and 55 ns with two. A sync.Map would allocate on every
26+// store and would need its own bound and eviction; a mutex-protected map would
27+// serialize the readers.
28+//
29+// Each slot is a seqlock made of atomics (so it is also clean for the race
30+// detector): a writer makes seq odd, writes the fields, makes it even again;
31+// a reader that sees an odd seq, or a seq that moved, takes a miss. A writer
32+// that finds the slot busy gives up: this is a cache.
33+type headerCache struct {
34+ slots []headerSlot
35+ shift uint // 64 - log2(len(slots))
36+}
37+
38+type headerSlot struct {
39+ seq atomic.Uint64
40+ off atomic.Uint64 // entry offset + 1; 0 is an empty slot
41+ val atomic.Uint64 // size<<3 | type
42+}
43+
44+const (
45+ headerSlotBytes = 24
46+ // maxHeaderSlots bounds a pack to 1.5 MiB of cache. A pack with more
47+ // deltified objects than that keeps the most recent ones per slot.
48+ maxHeaderSlots = 1 << 16
49+ minHeaderSlots = 64 // at least 2, a set is two slots
50+ // maxCachedSize is the largest size a slot holds (3 bits go to the type).
51+ maxCachedSize = 1 << 60
52+)
53+
54+// newHeaderCache sizes the table for a pack of objects objects: the next
55+// power of two, within the bounds.
56+func newHeaderCache(objects uint32) *headerCache {
57+ n := min(max(int(objects), minHeaderSlots), maxHeaderSlots)
58+ n = 1 << bits.Len(uint(n-1)) // round up to a power of two
59+ return &headerCache{slots: make([]headerSlot, n), shift: uint(64 - bits.TrailingZeros(uint(n)))}
60+}
61+
62+// index returns the position of the first of the two places of off.
63+func (c *headerCache) index(off uint64) (first int, h uint64) {
64+ h = off * 0x9e3779b97f4a7c15
65+ return int(h >> c.shift), h
66+}
67+
68+func (s *headerSlot) read(off uint64) (v uint64, ok bool) {
69+ seq := s.seq.Load()
70+ if seq&1 != 0 {
71+ return 0, false
72+ }
73+ o, v := s.off.Load(), s.val.Load()
74+ if o != off+1 || s.seq.Load() != seq {
75+ return 0, false
76+ }
77+ return v, true
78+}
79+
80+func (c *headerCache) get(off uint64) (Type, int64, bool) {
81+ i, _ := c.index(off)
82+ if v, ok := c.slots[i].read(off); ok {
83+ return Type(v & 7), int64(v >> 3), true
84+ }
85+ if v, ok := c.slots[i^1].read(off); ok {
86+ return Type(v & 7), int64(v >> 3), true
87+ }
88+ return 0, 0, false
89+}
90+
91+func (c *headerCache) put(off uint64, typ Type, size int64) {
92+ if size < 0 || size >= maxCachedSize {
93+ return
94+ }
95+ i, h := c.index(off)
96+ // An empty place first; else a pseudo-random one of the two.
97+ s := &c.slots[i]
98+ if s.off.Load() != 0 {
99+ if t := &c.slots[i^1]; t.off.Load() == 0 || h&(1<<20) != 0 {
100+ s = t
101+ }
102+ }
103+ seq := s.seq.Load()
104+ if seq&1 != 0 || !s.seq.CompareAndSwap(seq, seq+1) {
105+ return
106+ }
107+ s.off.Store(off + 1)
108+ s.val.Store(uint64(size)<<3 | uint64(typ))
109+ s.seq.Store(seq + 2)
110+}
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "math/bits"
7+ "sync/atomic"
8+)
9+
10+// headerCache remembers the type and the result size of the deltified
11+// objects of one pack, by entry offset. Finding that size means inflating the
12+// start of the delta, which dominated listing a directory with the size of
13+// each file (ObjectHeader per entry).
14+//
15+// A pack never changes, so an entry never goes stale and nothing invalidates
16+// it; the cache goes away with the pack. It is a fixed array of slots in
17+// which an offset has two places (a 2-way set: the slot its hash picks and
18+// the neighbour), and a newer entry replaces one of the two when both are
19+// taken. The memory is bounded by construction (headerSlotBytes per slot, at
20+// most maxHeaderSlots slots per pack, allocated at the first deltified
21+// header), a hit costs a hash and a few atomic loads and allocates nothing,
22+// and there is no lock, no eviction list and no allocation per entry. One way
23+// only made two hot offsets that collide evict each other on every lookup:
24+// 4,096 deltified headers read in rotation on lazygit (65,536 slots) cost
25+// 334 ns each with one way and 55 ns with two. A sync.Map would allocate on every
26+// store and would need its own bound and eviction; a mutex-protected map would
27+// serialize the readers.
28+//
29+// Each slot is a seqlock made of atomics (so it is also clean for the race
30+// detector): a writer makes seq odd, writes the fields, makes it even again;
31+// a reader that sees an odd seq, or a seq that moved, takes a miss. A writer
32+// that finds the slot busy gives up: this is a cache.
33+type headerCache struct {
34+ slots []headerSlot
35+ shift uint // 64 - log2(len(slots))
36+}
37+
38+type headerSlot struct {
39+ seq atomic.Uint64
40+ off atomic.Uint64 // entry offset + 1; 0 is an empty slot
41+ val atomic.Uint64 // size<<3 | type
42+}
43+
44+const (
45+ headerSlotBytes = 24
46+ // maxHeaderSlots bounds a pack to 1.5 MiB of cache. A pack with more
47+ // deltified objects than that keeps the most recent ones per slot.
48+ maxHeaderSlots = 1 << 16
49+ minHeaderSlots = 64 // at least 2, a set is two slots
50+ // maxCachedSize is the largest size a slot holds (3 bits go to the type).
51+ maxCachedSize = 1 << 60
52+)
53+
54+// newHeaderCache sizes the table for a pack of objects objects: the next
55+// power of two, within the bounds.
56+func newHeaderCache(objects uint32) *headerCache {
57+ n := min(max(int(objects), minHeaderSlots), maxHeaderSlots)
58+ n = 1 << bits.Len(uint(n-1)) // round up to a power of two
59+ return &headerCache{slots: make([]headerSlot, n), shift: uint(64 - bits.TrailingZeros(uint(n)))}
60+}
61+
62+// index returns the position of the first of the two places of off.
63+func (c *headerCache) index(off uint64) (first int, h uint64) {
64+ h = off * 0x9e3779b97f4a7c15
65+ return int(h >> c.shift), h
66+}
67+
68+func (s *headerSlot) read(off uint64) (v uint64, ok bool) {
69+ seq := s.seq.Load()
70+ if seq&1 != 0 {
71+ return 0, false
72+ }
73+ o, v := s.off.Load(), s.val.Load()
74+ if o != off+1 || s.seq.Load() != seq {
75+ return 0, false
76+ }
77+ return v, true
78+}
79+
80+func (c *headerCache) get(off uint64) (Type, int64, bool) {
81+ i, _ := c.index(off)
82+ if v, ok := c.slots[i].read(off); ok {
83+ return Type(v & 7), int64(v >> 3), true
84+ }
85+ if v, ok := c.slots[i^1].read(off); ok {
86+ return Type(v & 7), int64(v >> 3), true
87+ }
88+ return 0, 0, false
89+}
90+
91+func (c *headerCache) put(off uint64, typ Type, size int64) {
92+ if size < 0 || size >= maxCachedSize {
93+ return
94+ }
95+ i, h := c.index(off)
96+ // An empty place first; else a pseudo-random one of the two.
97+ s := &c.slots[i]
98+ if s.off.Load() != 0 {
99+ if t := &c.slots[i^1]; t.off.Load() == 0 || h&(1<<20) != 0 {
100+ s = t
101+ }
102+ }
103+ seq := s.seq.Load()
104+ if seq&1 != 0 || !s.seq.CompareAndSwap(seq, seq+1) {
105+ return
106+ }
107+ s.off.Store(off + 1)
108+ s.val.Store(uint64(size)<<3 | uint64(typ))
109+ s.seq.Store(seq + 2)
110+}
A headercache_test.go +141 -0
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "fmt"
7+ "sync"
8+ "testing"
9+)
10+
11+func TestHeaderCacheSizing(t *testing.T) {
12+ for _, c := range []struct {
13+ objects uint32
14+ slots int
15+ }{
16+ {0, minHeaderSlots}, {1, minHeaderSlots}, {64, 64}, {65, 128}, {1000, 1024},
17+ {maxHeaderSlots, maxHeaderSlots}, {maxHeaderSlots + 1, maxHeaderSlots}, {1 << 31, maxHeaderSlots},
18+ } {
19+ hc := newHeaderCache(c.objects)
20+ if len(hc.slots) != c.slots {
21+ t.Errorf("%d objects: %d slots, want %d", c.objects, len(hc.slots), c.slots)
22+ }
23+ }
24+ if max := maxHeaderSlots * headerSlotBytes; max > 2<<20 {
25+ t.Errorf("a pack may pin %d bytes of header cache", max)
26+ }
27+}
28+
29+func TestHeaderCacheGetPut(t *testing.T) {
30+ hc := newHeaderCache(1000)
31+ if _, _, ok := hc.get(12); ok {
32+ t.Fatal("hit in an empty cache")
33+ }
34+ hc.put(12, TypeTree, 4242)
35+ if typ, size, ok := hc.get(12); !ok || typ != TypeTree || size != 4242 {
36+ t.Fatalf("get = %v %v %v", typ, size, ok)
37+ }
38+ // Offset 0 and size 0 are values like others.
39+ hc.put(0, TypeBlob, 0)
40+ if typ, size, ok := hc.get(0); !ok || typ != TypeBlob || size != 0 {
41+ t.Fatalf("get(0) = %v %v %v", typ, size, ok)
42+ }
43+ // A size that does not fit a slot is not cached, not truncated.
44+ hc.put(99, TypeBlob, maxCachedSize)
45+ if _, _, ok := hc.get(99); ok {
46+ t.Fatal("oversized entry cached")
47+ }
48+ // Distinct offsets that share a slot never answer for each other.
49+ for off := uint64(1); off < 20000; off++ {
50+ hc.put(off, TypeBlob, int64(off)*3)
51+ }
52+ hits := 0
53+ for off := uint64(1); off < 20000; off++ {
54+ if typ, size, ok := hc.get(off); ok {
55+ hits++
56+ if typ != TypeBlob || size != int64(off)*3 {
57+ t.Fatalf("offset %d answered %v %d", off, typ, size)
58+ }
59+ }
60+ }
61+ if hits == 0 || hits > len(hc.slots) {
62+ t.Fatalf("%d hits for %d slots", hits, len(hc.slots))
63+ }
64+}
65+
66+func TestHeaderCacheConcurrent(t *testing.T) {
67+ hc := newHeaderCache(64)
68+ var wg sync.WaitGroup
69+ for g := range 8 {
70+ wg.Add(1)
71+ go func() {
72+ defer wg.Done()
73+ for i := range 20000 {
74+ off := uint64((i*7 + g) % 500)
75+ if typ, size, ok := hc.get(off); ok {
76+ if typ != TypeBlob || size != int64(off)*5 {
77+ t.Errorf("torn read at %d: %v %d", off, typ, size)
78+ return
79+ }
80+ } else {
81+ hc.put(off, TypeBlob, int64(off)*5)
82+ }
83+ }
84+ }()
85+ }
86+ wg.Wait()
87+}
88+
89+func TestHeaderCacheNoAllocOnHit(t *testing.T) {
90+ hc := newHeaderCache(64)
91+ hc.put(77, TypeBlob, 5)
92+ if got := testing.AllocsPerRun(100, func() { hc.get(77) }); got != 0 {
93+ t.Fatalf("a hit allocates %v", got)
94+ }
95+}
96+
97+// ObjectHeader answers the same with and without the cache for every object of
98+// a deltified pack, a second time too (from the cache), and a hit allocates
99+// nothing and does not inflate.
100+func TestObjectHeaderCached(t *testing.T) {
101+ f := newFx(t)
102+ for i := 0; i < 40; i++ {
103+ f.write("f.txt", append(textLines(300, 0), fmt.Sprintf("rev %d\n", i)...), 0o644)
104+ f.write(fmt.Sprintf("many/file-%03d.txt", i), []byte{byte(i)}, 0o644)
105+ f.commit(fmt.Sprintf("commit %d", i))
106+ }
107+ f.git("repack", "-adf", "--depth=50", "--window=250")
108+ r := f.open()
109+ p := r.snap.Load().packs[0]
110+ deltas := 0
111+ for i := range p.idx.n {
112+ off, _ := p.idx.offset(i)
113+ var id ObjectID
114+ copy(id[:], p.idx.id(i))
115+ wt, ws, _, err := p.readHeader(off) // uncached
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ for range 2 {
120+ gt, gs, err := r.ObjectHeader(id)
121+ if err != nil || gt != wt || gs != ws {
122+ t.Fatalf("%s: ObjectHeader = %v %d %v, want %v %d", id, gt, gs, err, wt, ws)
123+ }
124+ }
125+ if h, _ := parseEntryHeader(p.data, off); h.isDelta() {
126+ deltas++
127+ if _, _, ok := p.hdrs.Load().get(off); !ok && len(p.hdrs.Load().slots) >= int(p.idx.n) {
128+ t.Errorf("%s: deltified header not cached", id)
129+ }
130+ if raceEnabled {
131+ continue
132+ }
133+ if got := testing.AllocsPerRun(20, func() { _, _, _ = r.ObjectHeader(id) }); got != 0 {
134+ t.Errorf("%s: cached ObjectHeader allocates %v", id, got)
135+ }
136+ }
137+ }
138+ if deltas == 0 {
139+ t.Fatal("no deltified object in the fixture")
140+ }
141+}
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "fmt"
7+ "sync"
8+ "testing"
9+)
10+
11+func TestHeaderCacheSizing(t *testing.T) {
12+ for _, c := range []struct {
13+ objects uint32
14+ slots int
15+ }{
16+ {0, minHeaderSlots}, {1, minHeaderSlots}, {64, 64}, {65, 128}, {1000, 1024},
17+ {maxHeaderSlots, maxHeaderSlots}, {maxHeaderSlots + 1, maxHeaderSlots}, {1 << 31, maxHeaderSlots},
18+ } {
19+ hc := newHeaderCache(c.objects)
20+ if len(hc.slots) != c.slots {
21+ t.Errorf("%d objects: %d slots, want %d", c.objects, len(hc.slots), c.slots)
22+ }
23+ }
24+ if max := maxHeaderSlots * headerSlotBytes; max > 2<<20 {
25+ t.Errorf("a pack may pin %d bytes of header cache", max)
26+ }
27+}
28+
29+func TestHeaderCacheGetPut(t *testing.T) {
30+ hc := newHeaderCache(1000)
31+ if _, _, ok := hc.get(12); ok {
32+ t.Fatal("hit in an empty cache")
33+ }
34+ hc.put(12, TypeTree, 4242)
35+ if typ, size, ok := hc.get(12); !ok || typ != TypeTree || size != 4242 {
36+ t.Fatalf("get = %v %v %v", typ, size, ok)
37+ }
38+ // Offset 0 and size 0 are values like others.
39+ hc.put(0, TypeBlob, 0)
40+ if typ, size, ok := hc.get(0); !ok || typ != TypeBlob || size != 0 {
41+ t.Fatalf("get(0) = %v %v %v", typ, size, ok)
42+ }
43+ // A size that does not fit a slot is not cached, not truncated.
44+ hc.put(99, TypeBlob, maxCachedSize)
45+ if _, _, ok := hc.get(99); ok {
46+ t.Fatal("oversized entry cached")
47+ }
48+ // Distinct offsets that share a slot never answer for each other.
49+ for off := uint64(1); off < 20000; off++ {
50+ hc.put(off, TypeBlob, int64(off)*3)
51+ }
52+ hits := 0
53+ for off := uint64(1); off < 20000; off++ {
54+ if typ, size, ok := hc.get(off); ok {
55+ hits++
56+ if typ != TypeBlob || size != int64(off)*3 {
57+ t.Fatalf("offset %d answered %v %d", off, typ, size)
58+ }
59+ }
60+ }
61+ if hits == 0 || hits > len(hc.slots) {
62+ t.Fatalf("%d hits for %d slots", hits, len(hc.slots))
63+ }
64+}
65+
66+func TestHeaderCacheConcurrent(t *testing.T) {
67+ hc := newHeaderCache(64)
68+ var wg sync.WaitGroup
69+ for g := range 8 {
70+ wg.Add(1)
71+ go func() {
72+ defer wg.Done()
73+ for i := range 20000 {
74+ off := uint64((i*7 + g) % 500)
75+ if typ, size, ok := hc.get(off); ok {
76+ if typ != TypeBlob || size != int64(off)*5 {
77+ t.Errorf("torn read at %d: %v %d", off, typ, size)
78+ return
79+ }
80+ } else {
81+ hc.put(off, TypeBlob, int64(off)*5)
82+ }
83+ }
84+ }()
85+ }
86+ wg.Wait()
87+}
88+
89+func TestHeaderCacheNoAllocOnHit(t *testing.T) {
90+ hc := newHeaderCache(64)
91+ hc.put(77, TypeBlob, 5)
92+ if got := testing.AllocsPerRun(100, func() { hc.get(77) }); got != 0 {
93+ t.Fatalf("a hit allocates %v", got)
94+ }
95+}
96+
97+// ObjectHeader answers the same with and without the cache for every object of
98+// a deltified pack, a second time too (from the cache), and a hit allocates
99+// nothing and does not inflate.
100+func TestObjectHeaderCached(t *testing.T) {
101+ f := newFx(t)
102+ for i := 0; i < 40; i++ {
103+ f.write("f.txt", append(textLines(300, 0), fmt.Sprintf("rev %d\n", i)...), 0o644)
104+ f.write(fmt.Sprintf("many/file-%03d.txt", i), []byte{byte(i)}, 0o644)
105+ f.commit(fmt.Sprintf("commit %d", i))
106+ }
107+ f.git("repack", "-adf", "--depth=50", "--window=250")
108+ r := f.open()
109+ p := r.snap.Load().packs[0]
110+ deltas := 0
111+ for i := range p.idx.n {
112+ off, _ := p.idx.offset(i)
113+ var id ObjectID
114+ copy(id[:], p.idx.id(i))
115+ wt, ws, _, err := p.readHeader(off) // uncached
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ for range 2 {
120+ gt, gs, err := r.ObjectHeader(id)
121+ if err != nil || gt != wt || gs != ws {
122+ t.Fatalf("%s: ObjectHeader = %v %d %v, want %v %d", id, gt, gs, err, wt, ws)
123+ }
124+ }
125+ if h, _ := parseEntryHeader(p.data, off); h.isDelta() {
126+ deltas++
127+ if _, _, ok := p.hdrs.Load().get(off); !ok && len(p.hdrs.Load().slots) >= int(p.idx.n) {
128+ t.Errorf("%s: deltified header not cached", id)
129+ }
130+ if raceEnabled {
131+ continue
132+ }
133+ if got := testing.AllocsPerRun(20, func() { _, _, _ = r.ObjectHeader(id) }); got != 0 {
134+ t.Errorf("%s: cached ObjectHeader allocates %v", id, got)
135+ }
136+ }
137+ }
138+ if deltas == 0 {
139+ t.Fatal("no deltified object in the fixture")
140+ }
141+}
M pack.go +37 -10
@@ -29,6 +29,8 @@ type pack struct {
2929 sig fileSig
3030 unmap [2]func() error
3131 refs atomic.Int32
32+
33+ hdrs atomic.Pointer[headerCache] // created by the first deltified header
3234 }
3335
3436 func (p *pack) retain() { p.refs.Add(1) }
@@ -253,40 +255,65 @@ func (p *pack) baseOffset(h *entryHdr, self uint64) (uint64, error) {
253255 return off, nil
254256 }
255257
256-// header returns the type and size of the object at off.
258+// header returns the type and size of the object at off. The result of a
259+// deltified object, which costs an inflate to find, is cached per pack.
257260 func (p *pack) header(off uint64) (Type, int64, error) {
261+ hc := p.hdrs.Load()
262+ if hc != nil {
263+ if typ, size, ok := hc.get(off); ok {
264+ return typ, size, nil
265+ }
266+ }
267+ typ, size, deltified, err := p.readHeader(off)
268+ if err != nil || !deltified {
269+ return typ, size, err
270+ }
271+ if hc == nil {
272+ hc = newHeaderCache(p.idx.n)
273+ if !p.hdrs.CompareAndSwap(nil, hc) {
274+ hc = p.hdrs.Load()
275+ }
276+ }
277+ hc.put(off, typ, size)
278+ return typ, size, nil
279+}
280+
281+// readHeader reads the type and size of the object at off from the pack.
282+// deltified tells that the size comes from the start of a delta, which it
283+// had to inflate.
284+func (p *pack) readHeader(off uint64) (_ Type, _ int64, deltified bool, _ error) {
258285 h, err := parseEntryHeader(p.data, off)
259286 if err != nil {
260- return 0, 0, err
287+ return 0, 0, false, err
261288 }
262289 if !h.isDelta() {
263290 if h.size > 1<<62 {
264- return 0, 0, corrupt("entry size %d", h.size)
291+ return 0, 0, false, corrupt("entry size %d", h.size)
265292 }
266- return Type(h.typ), int64(h.size), nil
293+ return Type(h.typ), int64(h.size), false, nil
267294 }
268295 typ, err := p.chainEnd(off, h)
269296 if err != nil {
270- return 0, 0, err
297+ return 0, 0, false, err
271298 }
272299 // The result size is the second varint of the delta, at its start.
273300 m, err := getMem(p.stream(&h))
274301 if err != nil {
275- return 0, 0, err
302+ return 0, 0, false, err
276303 }
277304 defer putMem(m)
278305 n := min(uint64(len(m.scratch)), h.size)
279306 if _, err := io.ReadFull(&m.zr, m.scratch[:n]); err != nil {
280- return 0, 0, corrupt("delta header: %v", err)
307+ return 0, 0, false, corrupt("delta header: %v", err)
281308 }
282309 _, rs, _, err := deltaHeader(m.scratch[:n])
283310 if err != nil {
284- return 0, 0, err
311+ return 0, 0, false, err
285312 }
286313 if rs > 1<<62 {
287- return 0, 0, corrupt("delta result size %d", rs)
314+ return 0, 0, false, corrupt("delta result size %d", rs)
288315 }
289- return typ, int64(rs), nil
316+ return typ, int64(rs), true, nil
290317 }
291318
292319 // readPacked resolves the object at off into dst (grown as needed) and
@@ -29,6 +29,8 @@ type pack struct {
29 sig fileSig29 sig fileSig
30 unmap [2]func() error30 unmap [2]func() error
31 refs atomic.Int3231 refs atomic.Int32
32+
33+ hdrs atomic.Pointer[headerCache] // created by the first deltified header
32 }34 }
33 35
34 func (p *pack) retain() { p.refs.Add(1) }36 func (p *pack) retain() { p.refs.Add(1) }
@@ -253,40 +255,65 @@ func (p *pack) baseOffset(h *entryHdr, self uint64) (uint64, error) {
253 return off, nil255 return off, nil
254 }256 }
255 257
256-// header returns the type and size of the object at off.258+// header returns the type and size of the object at off. The result of a
259+// deltified object, which costs an inflate to find, is cached per pack.
257 func (p *pack) header(off uint64) (Type, int64, error) {260 func (p *pack) header(off uint64) (Type, int64, error) {
261+ hc := p.hdrs.Load()
262+ if hc != nil {
263+ if typ, size, ok := hc.get(off); ok {
264+ return typ, size, nil
265+ }
266+ }
267+ typ, size, deltified, err := p.readHeader(off)
268+ if err != nil || !deltified {
269+ return typ, size, err
270+ }
271+ if hc == nil {
272+ hc = newHeaderCache(p.idx.n)
273+ if !p.hdrs.CompareAndSwap(nil, hc) {
274+ hc = p.hdrs.Load()
275+ }
276+ }
277+ hc.put(off, typ, size)
278+ return typ, size, nil
279+}
280+
281+// readHeader reads the type and size of the object at off from the pack.
282+// deltified tells that the size comes from the start of a delta, which it
283+// had to inflate.
284+func (p *pack) readHeader(off uint64) (_ Type, _ int64, deltified bool, _ error) {
258 h, err := parseEntryHeader(p.data, off)285 h, err := parseEntryHeader(p.data, off)
259 if err != nil {286 if err != nil {
260- return 0, 0, err287+ return 0, 0, false, err
261 }288 }
262 if !h.isDelta() {289 if !h.isDelta() {
263 if h.size > 1<<62 {290 if h.size > 1<<62 {
264- return 0, 0, corrupt("entry size %d", h.size)291+ return 0, 0, false, corrupt("entry size %d", h.size)
265 }292 }
266- return Type(h.typ), int64(h.size), nil293+ return Type(h.typ), int64(h.size), false, nil
267 }294 }
268 typ, err := p.chainEnd(off, h)295 typ, err := p.chainEnd(off, h)
269 if err != nil {296 if err != nil {
270- return 0, 0, err297+ return 0, 0, false, err
271 }298 }
272 // The result size is the second varint of the delta, at its start.299 // The result size is the second varint of the delta, at its start.
273 m, err := getMem(p.stream(&h))300 m, err := getMem(p.stream(&h))
274 if err != nil {301 if err != nil {
275- return 0, 0, err302+ return 0, 0, false, err
276 }303 }
277 defer putMem(m)304 defer putMem(m)
278 n := min(uint64(len(m.scratch)), h.size)305 n := min(uint64(len(m.scratch)), h.size)
279 if _, err := io.ReadFull(&m.zr, m.scratch[:n]); err != nil {306 if _, err := io.ReadFull(&m.zr, m.scratch[:n]); err != nil {
280- return 0, 0, corrupt("delta header: %v", err)307+ return 0, 0, false, corrupt("delta header: %v", err)
281 }308 }
282 _, rs, _, err := deltaHeader(m.scratch[:n])309 _, rs, _, err := deltaHeader(m.scratch[:n])
283 if err != nil {310 if err != nil {
284- return 0, 0, err311+ return 0, 0, false, err
285 }312 }
286 if rs > 1<<62 {313 if rs > 1<<62 {
287- return 0, 0, corrupt("delta result size %d", rs)314+ return 0, 0, false, corrupt("delta result size %d", rs)
288 }315 }
289- return typ, int64(rs), nil316+ return typ, int64(rs), true, nil
290 }317 }
291 318
292 // readPacked resolves the object at off into dst (grown as needed) and319 // readPacked resolves the object at off into dst (grown as needed) and

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