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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 29 | 29 | sig fileSig | |
| 30 | 30 | unmap [2]func() error | |
| 31 | 31 | refs atomic.Int32 | |
| 32 | + | ||
| 33 | + | hdrs atomic.Pointer[headerCache] // created by the first deltified header | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | func (p *pack) retain() { p.refs.Add(1) } | |
| @@ -253,40 +255,65 @@ func (p *pack) baseOffset(h *entryHdr, self uint64) (uint64, error) { | |||
| 253 | 255 | 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 | 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 | 285 | h, err := parseEntryHeader(p.data, off) | |
| 259 | 286 | if err != nil { | |
| 260 | - | return 0, 0, err | |
| 287 | + | return 0, 0, false, err | |
| 261 | 288 | } | |
| 262 | 289 | if !h.isDelta() { | |
| 263 | 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), nil | |
| 293 | + | return Type(h.typ), int64(h.size), false, nil | |
| 267 | 294 | } | |
| 268 | 295 | typ, err := p.chainEnd(off, h) | |
| 269 | 296 | if err != nil { | |
| 270 | - | return 0, 0, err | |
| 297 | + | return 0, 0, false, err | |
| 271 | 298 | } | |
| 272 | 299 | // The result size is the second varint of the delta, at its start. | |
| 273 | 300 | m, err := getMem(p.stream(&h)) | |
| 274 | 301 | if err != nil { | |
| 275 | - | return 0, 0, err | |
| 302 | + | return 0, 0, false, err | |
| 276 | 303 | } | |
| 277 | 304 | defer putMem(m) | |
| 278 | 305 | n := min(uint64(len(m.scratch)), h.size) | |
| 279 | 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 | 309 | _, rs, _, err := deltaHeader(m.scratch[:n]) | |
| 283 | 310 | if err != nil { | |
| 284 | - | return 0, 0, err | |
| 311 | + | return 0, 0, false, err | |
| 285 | 312 | } | |
| 286 | 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), nil | |
| 316 | + | return typ, int64(rs), true, nil | |
| 290 | 317 | } | |
| 291 | 318 | ||
| 292 | 319 | // readPacked resolves the object at off into dst (grown as needed) and | |
| @@ -29,6 +29,8 @@ type pack struct { | |||||
| 29 | sig fileSig | 29 | sig fileSig | ||
| 30 | unmap [2]func() error | 30 | unmap [2]func() error | ||
| 31 | refs atomic.Int32 | 31 | 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, nil | 255 | 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, err | 287 | + | 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), nil | 293 | + | 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, err | 297 | + | 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, err | 302 | + | 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, err | 311 | + | 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), nil | 316 | + | return typ, int64(rs), true, nil |
| 290 | } | 317 | } | ||
| 291 | 318 | ||||
| 292 | // readPacked resolves the object at off into dst (grown as needed) and | 319 | // readPacked resolves the object at off into dst (grown as needed) and | ||