From: Jeff King <hidden> Date: 2016-07-29 04:04:31
This is a follow-up to the patches in
http://public-inbox.org/git/20160725184938.GA12871@sigill.intra.peff.net/
that are currently queued in jk/pack-objects-optim-skimming. Roughly,
they try to optimize a loop that is O(nr_objects * nr_packs) by breaking
out early in some cases.
I had written those patches a while ago and confirmed that they did
speed up a particular nasty case I had. But when I tried to write a
t/perf test to show off the improvement, I found that they didn't help!
The reason is that the optimizations are heavily dependent on the order
of the packs, and which objects go in which pack. The loop has the same
worst-case complexity as it always did, but we rely on getting lucky to
break out early.
I think the perf test I've included here is more representative of a
real-world workloads, and with an extra optimization, I was able to show
good numbers with it.
The general strategy is to order the pack lookups in most-recently-used
order. This replaces an existing 1-element MRU cache in the normal pack
lookup code, and replaces a straight reverse-chronological iteration in
pack-objects.
All credit for thinking of this scheme goes to Michael Haggerty, who
suggested the idea to me about six months ago. It seemed like a lot of
work at the time, so I didn't do it. :) But as I started to implement
the same 1-element cache in pack-objects, I found that the code actually
gets rather awkward. The MRU solution makes the callers easier to read,
and of course it turns out to be faster, to boot.
Anyway, enough chit-chat. The patches are:
[1/7]: t/perf: add tests for many-pack scenarios
[2/7]: sha1_file: drop free_pack_by_name
[3/7]: add generic most-recently-used list
[4/7]: find_pack_entry: replace last_found_pack with MRU cache
[5/7]: pack-objects: break out of want_object loop early
[6/7]: pack-objects: compute local/ignore_pack_keep early
[7/7]: pack-objects: use mru list when iterating over packs
The actual optimizations are in patches 4 and 7, which have their own
numbers. But here are end-to-end numbers for the series against the tip
of master (for the meanings, see the discussion in patch 1, and the
analysis in 4 and 7):
[p5303, linux.git]
Test origin HEAD
-------------------------------------------------------------------------
5303.3: rev-list (1) 31.48(31.20+0.27) 31.18(30.95+0.22) -1.0%
5303.4: repack (1) 40.74(39.27+2.56) 40.30(38.96+2.47) -1.1%
5303.6: rev-list (50) 31.65(31.38+0.26) 31.26(31.02+0.23) -1.2%
5303.7: repack (50) 60.90(71.03+2.13) 46.95(57.46+1.85) -22.9%
5303.9: rev-list (1000) 38.63(38.25+0.37) 31.91(31.61+0.28) -17.4%
5303.10: repack (1000) 392.52(467.09+5.05) 87.38(159.98+2.92) -77.7%
[p5303, git.git]
Test origin HEAD
---------------------------------------------------------------------
5303.3: rev-list (1) 1.55(1.54+0.00) 1.56(1.54+0.01) +0.6%
5303.4: repack (1) 1.83(1.82+0.06) 1.82(1.82+0.05) -0.5%
5303.6: rev-list (50) 1.58(1.56+0.02) 1.58(1.57+0.00) +0.0%
5303.7: repack (50) 2.50(3.16+0.04) 2.32(2.92+0.09) -7.2%
5303.9: rev-list (1000) 2.64(2.61+0.02) 2.23(2.21+0.01) -15.5%
5303.10: repack (1000) 12.68(19.07+0.30) 7.51(13.86+0.20) -40.8%
For curiosity, I also ran the git.git case with 10,000 packs. This is
even more silly, but it shows that the problem does get worse and worse
as the number grows, but that the patches do continue to help:
Test origin HEAD
-------------------------------------------------------------------------
5303.12: rev-list (10,000) 26.00(25.86+0.13) 15.76(15.62+0.13) -39.4%
5303.13: repack (10,000) 164.11(175.30+1.34) 51.48(62.96+1.18) -68.6%
-Peff
From: Jeff King <hidden> Date: 2016-07-29 04:06:16
Git's pack storage does efficient (log n) lookups in a
single packfile's index, but if we have multiple packfiles,
we have to linearly search each for a given object. This
patch introduces some timing tests for cases where we have a
large number of packs, so that we can measure any
improvements we make in the following patches.
The main thing we want to time is object lookup. To do this,
we measure "git rev-list --objects --all", which does a
fairly large number of object lookups (essentially one per
object in the repository).
However, we also measure the time to do a full repack, which
is interesting for two reasons. One is that in addition to
the usual pack lookup, it has its own linear iteration over
the list of packs. And two is that because it it is the tool
one uses to go from an inefficient many-pack situation back
to a single pack, we care about its performance not only at
marginal numbers of packs, but at the extreme cases (e.g.,
if you somehow end up with 5,000 packs, it is the only way
to get back to 1 pack, so we need to make sure it performs
well).
We measure the performance of each command in three
scenarios: 1 pack, 50 packs, and 1,000 packs.
The 1-pack case is a baseline; any optimizations we do to
handle multiple packs cannot possibly perform better than
this.
The 50-pack case is as far as Git should generally allow
your repository to go, if you have auto-gc enabled with the
default settings. So this represents the maximum performance
improvement we would expect under normal circumstances.
The 1,000-pack case is hopefully rare, though I have seen it
in the wild where automatic maintenance was broken for some
time (and the repository continued to receive pushes). This
represents cases where we care less about general
performance, but want to make sure that a full repack
command does not take excessively long.
Signed-off-by: Jeff King <redacted>
---
By the way, a caution before you run this. It takes one the order of an
hour to run on linux.git. So if you're comparing a few builds, be
patient. :)
t/perf/p5303-many-packs.sh | 87 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100755 t/perf/p5303-many-packs.sh
@@ -0,0 +1,87 @@+#!/bin/sh++test_description='performance with large numbers of packs'+../perf-lib.sh++test_perf_large_repo++# A real many-pack situation would probably come from having a lot of pushes+# over time. We don't know how big each push would be, but we can fake it by+# just walking the first-parent chain and having every 5 commits be their own+# "push". This isn't _entirely_ accurate, as real pushes would have some+# duplicate objects due to thin-pack fixing, but it's a reasonable+# approximation.+#+# And then all of the rest of the objects can go in a single packfile that+# represents the state before any of those pushes (actually, we'll generate+# that first because in such a setup it would be the oldest pack, and we sort+# the packs by reverse mtime inside git).+repack_into_n(){+rm-rfstaging&&+mkdirstaging&&++gitrev-list--first-parentHEAD|+sed-n'1~5p'|+head-n"$1"|+perl-e'print reverse <>'\+>pushes++# create base packfile+head-n1pushes|+gitpack-objects--delta-base-offset--revsstaging/pack++# and then incrementals between each pair of commits+last=&&+whilereadrev+do+iftest-n"$last";then+{+echo"$rev"&&+echo"^$last"+}|+gitpack-objects--delta-base-offset--revs\+staging/pack||return1+fi+last=$rev+done<pushes&&++# and install the whole thing+rm-f.git/objects/pack/*&&+mvstaging/*.git/objects/pack/+}++# Pretend we just have a single branch and no reflogs, and that everything is+# in objects/pack; that makes our fake pack-building via repack_into_n()+# much simpler.+test_expect_success'simplify reachability''+tip=$(gitrev-parse--verifyHEAD)&&+gitfor-each-ref--format="option no-deref%0adelete %(refname)"|+gitupdate-ref--stdin&&+rm-rf.git/logs&&+gitupdate-refrefs/heads/master$tip&&+gitsymbolic-refHEADrefs/heads/master&&+gitrepack-ad+'++fornr_packsin1501000+do+test_expect_success"create $nr_packs-pack scenario"'+repack_into_n$nr_packs+'++test_perf"rev-list ($nr_packs)"'+gitrev-list--objects--all>/dev/null+'++# This simulates the interesting part of the repack, which is the+# actual pack generation, without smudging the on-disk setup+# between trials.+test_perf"repack ($nr_packs)"'+gitpack-objects--keep-true-parents\+--honor-pack-keep--non-empty--all\+--reflog--indexed-objects--delta-base-offset\+--stdout</dev/null>/dev/null+'+done++test_done
From: Jeff King <hidden> Date: 2016-07-29 04:06:59
The point of this function is to drop an entry from the
"packed_git" cache that points to a file we might be
overwriting, because our contents may not be the same (and
hence the only caller was pack-objects as it moved a
temporary packfile into place).
In older versions of git, this could happen because the
names of packfiles were derived from the set of objects they
contained, not the actual bits on disk. But since 1190a1a
(pack-objects: name pack files after trailer hash,
2013-12-05), the name reflects the actual bits on disk, and
any two packfiles with the same name can be used
interchangeably.
Dropping this function not only saves a few lines of code,
it makes the lifetime of "struct packed_git" much easier to
reason about: namely, we now do not ever free these structs.
Signed-off-by: Jeff King <redacted>
---
cache.h | 1 -
pack-write.c | 1 -
sha1_file.c | 30 ------------------------------
3 files changed, 32 deletions(-)
@@ -354,7 +354,6 @@ void finish_tmp_packfile(struct strbuf *name_buffer,die_errno("unable to make temporary index file readable");strbuf_addf(name_buffer,"%s.pack",sha1_to_hex(sha1));-free_pack_by_name(name_buffer->buf);if(rename(pack_tmp_name,name_buffer->buf))die_errno("unable to rename temporary pack file");
From: Jeff King <hidden> Date: 2016-07-29 04:07:07
There are a few places in Git that would benefit from a fast
most-recently-used cache (e.g., the list of packs, which we
search linearly but would like to order based on locality).
This patch introduces a generic list that can be used to
store arbitrary pointers in most-recently-used order.
The implementation is just a doubly-linked list, where
"marking" an item as used moves it to the front of the list.
Insertion and marking are O(1), and iteration is O(n).
There's no lookup support provided; if you need fast
lookups, you are better off with a different data structure
in the first place.
There is also no deletion support. This would not be hard to
do, but it's not necessary for handling pack structs, which
are created and never removed.
Signed-off-by: Jeff King <redacted>
---
Makefile | 1 +
mru.c | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
mru.h | 45 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 96 insertions(+)
create mode 100644 mru.c
create mode 100644 mru.h
@@ -0,0 +1,50 @@+#include"cache.h"+#include"mru.h"++voidmru_append(structmru*mru,void*item)+{+structmru_entry*cur=xmalloc(sizeof(*cur));+cur->item=item;+cur->prev=mru->tail;+cur->next=NULL;++if(mru->tail)+mru->tail->next=cur;+else+mru->head=cur;+mru->tail=cur;+}++voidmru_mark(structmru*mru,structmru_entry*entry)+{+/* If we're already at the front of the list, nothing to do */+if(mru->head==entry)+return;++/* Otherwise, remove us from our current slot... */+if(entry->prev)+entry->prev->next=entry->next;+if(entry->next)+entry->next->prev=entry->prev;+else+mru->tail=entry->prev;++/* And insert us at the beginning. */+entry->prev=NULL;+entry->next=mru->head;+if(mru->head)+mru->head->prev=entry;+mru->head=entry;+}++voidmru_clear(structmru*mru)+{+structmru_entry*p=mru->head;++while(p){+structmru_entry*to_free=p;+p=p->next;+free(to_free);+}+mru->head=mru->tail=NULL;+}
@@ -0,0 +1,45 @@+#ifndef MRU_H+#define MRU_H++/**+*Asimplemost-recently-usedcache,backedbyadoubly-linkedlist.+*+*Usageisroughly:+*+*// Create a list. Zero-initialization is required.+*staticstructmrucache;+*mru_append(&cache,item);+*...+*+*// Iterate in MRU order.+*structmru_entry*p;+*for(p=cache.head;p;p=p->next){+*if(matches(p->item))+*break;+*}+*+*// Mark an item as used, moving it to the front of the list.+*mru_mark(&cache,p);+*+*// Reset the list to empty, cleaning up all resources.+*mru_clear(&cache);+*+*NotethatyouSHOULDNOTcallmru_mark()andthencontinuetraversingthe+*list;itreordersthemarkeditemtothefrontofthelist,andtherefore+*youwillbegintraversingthewholelistagain.+*/++structmru_entry{+void*item;+structmru_entry*prev,*next;+};++structmru{+structmru_entry*head,*tail;+};++voidmru_append(structmru*mru,void*item);+voidmru_mark(structmru*mru,structmru_entry*entry);+voidmru_clear(structmru*mru);++#endif /* MRU_H */
From: Jeff King <hidden> Date: 2016-07-29 04:09:54
Each pack has an index for looking up entries in O(log n)
time, but if we have multiple packs, we have to scan through
them linearly. This can produce a measurable overhead for
some operations.
We dealt with this long ago in f7c22cc (always start looking
up objects in the last used pack first, 2007-05-30), which
keeps what is essentially a 1-element most-recently-used
cache. In theory, we should be able to do better by keeping
a similar but longer cache, that is the same length as the
pack-list itself.
Since we now have a convenient generic MRU structure, we can
plug it in and measure. Here are the numbers for running
p5303 against linux.git:
Test HEAD^ HEAD
------------------------------------------------------------------------
5303.3: rev-list (1) 31.56(31.28+0.27) 31.30(31.08+0.20) -0.8%
5303.4: repack (1) 40.62(39.35+2.36) 40.60(39.27+2.44) -0.0%
5303.6: rev-list (50) 31.31(31.06+0.23) 31.23(31.00+0.22) -0.3%
5303.7: repack (50) 58.65(69.12+1.94) 58.27(68.64+2.05) -0.6%
5303.9: rev-list (1000) 38.74(38.40+0.33) 31.87(31.62+0.24) -17.7%
5303.10: repack (1000) 367.20(441.80+4.62) 342.00(414.04+3.72) -6.9%
The main numbers of interest here are the rev-list ones
(since that is exercising the normal object lookup code
path). The single-pack case shouldn't improve at all; the
260ms speedup there is just part of the run-to-run noise
(but it's important to note that we didn't make anything
worse with the overhead of maintaining our cache). In the
50-pack case, we see similar results. There may be a slight
improvement, but it's mostly within the noise.
The 1000-pack case does show a big improvement, though. That
carries over to the repack case, as well. Even though we
haven't touched its pack-search loop yet, it does still do a
lot of normal object lookups (e.g., for the internal
revision walk), and so improves.
As a point of reference, I also ran the 1000-pack test
against a version of HEAD^ with the last_found_pack
optimization disabled. It takes ~60s, so that gives an
indication of how much even the single-element cache is
helping.
For comparison, here's a smaller repository, git.git:
Test HEAD^ HEAD
---------------------------------------------------------------------
5303.3: rev-list (1) 1.56(1.54+0.01) 1.54(1.51+0.02) -1.3%
5303.4: repack (1) 1.84(1.80+0.10) 1.82(1.80+0.09) -1.1%
5303.6: rev-list (50) 1.58(1.55+0.02) 1.59(1.57+0.01) +0.6%
5303.7: repack (50) 2.50(3.18+0.04) 2.50(3.14+0.04) +0.0%
5303.9: rev-list (1000) 2.76(2.71+0.04) 2.24(2.21+0.02) -18.8%
5303.10: repack (1000) 13.21(19.56+0.25) 11.66(18.01+0.21) -11.7%
You can see that the percentage improvement is similar.
That's because the lookup we are optimizing is roughly
O(nr_objects * nr_packs). Since the number of packs is
constant in both tests, we'd expect the improvement to be
linear in the number of objects. But the whole process is
also linear in the number of objects, so the improvement
is a constant factor.
The exact improvement does also depend on the contents of
the packs. In p5303, the extra packs all have 5 first-parent
commits in them, which is a reasonable simulation of a
pushed-to repository. But it also means that only 250
first-parent commits are in those packs (compared to almost
50,000 total in linux.git), and the rest are in the huge
"base" pack. So once we start looking at history in taht big
pack, that's where we'll find most everything, and even the
1-element cache gets close to 100% cache hits. You could
almost certainly show better numbers with a more
pathological case (e.g., distributing the objects more
evenly across the packs). But that's simply not that
realistic a scenario, so it makes more sense to focus on
these numbers.
The implementation itself is a straightforward application
of the MRU code. We provide an MRU-ordered list of packs
that shadows the packed_git list. This is easy to do because
we only create and revise the pack list in one place. The
"reprepare" code path actually drops the whole MRU and
replaces it for simplicity. It would be more efficient to
just add new entries, but there's not much point in
optimizing here; repreparing happens rarely, and only after
doing a lot of other expensive work. The key things to keep
optimized are traversal (which is just a normal linked list,
albeit with one extra level of indirection over the regular
packed_git list), and marking (which is a constant number of
pointer assignments, though slightly more than the old
last_found_pack was; it doesn't seem to create a measurable
slowdown, though).
Signed-off-by: Jeff King <redacted>
---
I could see an argument against this, which is basically:
- this is touching a really critical and core part of the code
- in normal cases, we should never grow beyond 50 packs
- it seems to be a wash at 50 packs
So it's all risk and no benefit. But it definitely _does_ help in the
more pathological cases, which are sadly a thing I have seen more than
once. So I tried hard to show that it does no harm, performance-wise,
for the smaller cases. As for complexity, you can be the judge. I think
the call-site here is improved, but of course that's because the
complexity is hidden in the mru_mark() function.
cache.h | 7 +++++++
sha1_file.c | 36 ++++++++++++++++++------------------
2 files changed, 25 insertions(+), 18 deletions(-)
@@ -2574,21 +2580,15 @@ static int fill_pack_entry(const unsigned char *sha1,*/staticintfind_pack_entry(constunsignedchar*sha1,structpack_entry*e){-structpacked_git*p;+structmru_entry*p;prepare_packed_git();if(!packed_git)return0;-if(last_found_pack&&fill_pack_entry(sha1,e,last_found_pack))-return1;--for(p=packed_git;p;p=p->next){-if(p==last_found_pack)-continue;/* we already checked this one */--if(fill_pack_entry(sha1,e,p)){-last_found_pack=p;+for(p=packed_git_mru->head;p;p=p->next){+if(fill_pack_entry(sha1,e,p->item)){+mru_mark(packed_git_mru,p);return1;}}
From: Jeff King <hidden> Date: 2016-07-29 04:10:38
When pack-objects collects the list of objects to pack
(either from stdin, or via its internal rev-list), it
filters each one through want_object_in_pack().
This function loops through each existing packfile, looking
for the object. When we find it, we mark the pack/offset
combo for later use. However, we can't just return "yes, we
want it" at that point. If --honor-pack-keep is in effect,
we must keep looking to find it in _all_ packs, to make sure
none of them has a .keep. Likewise, if --local is in effect,
we must make sure it is not present in any non-local pack.
As a result, the sum effort of these calls is effectively
O(nr_objects * nr_packs). In an ordinary repository, we have
only a handful of packs, and this doesn't make a big
difference. But in pathological cases, it can slow the
counting phase to a crawl.
This patch notices the case that we have neither "--local"
nor "--honor-pack-keep" in effect and breaks out of the loop
early, after finding the first instance. Note that our worst
case is still "objects * packs" (i.e., we might find each
object in the last pack we look in), but in practice we will
often break out early. On an "average" repo, my git.git with
8 packs, this shows a modest 2% (a few dozen milliseconds)
improvement in the counting-objects phase of "git
pack-objects --all <foo" (hackily instrumented by sticking
exit(0) right after list_objects).
But in a much more pathological case, it makes a bigger
difference. I ran the same command on a real-world example
with ~9 million objects across 1300 packs. The counting time
dropped from 413s to 45s, an improvement of about 89%.
Note that this patch won't do anything by itself for a
normal "git gc", as it uses both --honor-pack-keep and
--local.
Signed-off-by: Jeff King <redacted>
---
Same as earlier, though I took the re-ordering and comment from Junio
that came out of the earlier discussion.
builtin/pack-objects.c | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
From: Jeff King <hidden> Date: 2016-07-29 04:11:37
In want_object_in_pack(), we can exit early from our loop if
neither "local" nor "ignore_pack_keep" are set. If they are,
however, we must examine each pack to see if it has the
object and is non-local or has a ".keep".
It's quite common for there to be no non-local or .keep
packs at all, in which case we know ahead of time that
looking further will be pointless. We can pre-compute this
by simply iterating over the list of packs ahead of time,
and dropping the flags if there are no packs that could
match.
Another similar strategy would be to modify the loop in
want_object_in_pack() to notice that we have already found
the object once, and that we are looping only to check for
"local" and "keep" attributes. If a pack has neither of
those, we can skip the call to find_pack_entry_one(), which
is the expensive part of the loop.
This has two advantages:
- it isn't all-or-nothing; we still get some improvement
when there's a small number of kept or non-local packs,
and a large number of non-kept local packs
- it eliminates any possible race where we add new
non-local or kept packs after our initial scan. In
practice, I don't think this race matters; we already
cache the packed_git information, so somebody who adds a
new pack or .keep file after we've started will not be
noticed at all, unless we happen to need to call
reprepare_packed_git() because a lookup fails.
In other words, we're already racy, and the race is not
a big deal (losing the race means we might include an
object in the pack that would not otherwise be, which is
an acceptable outcome).
However, it also has a disadvantage: we still loop over the
rest of the packs for each object to check their flags. This
is much less expensive than doing the object lookup, but
still not free. So if we wanted to implement that strategy
to cover the non-all-or-nothing cases, we could do so in
addition to this one (so you get the most speedup in the
all-or-nothing case, and the best we can do in the other
cases). But given that the all-or-nothing case is likely the
most common, it is probably not worth the trouble, and we
can revisit this later if evidence points otherwise.
Signed-off-by: Jeff King <redacted>
---
builtin/pack-objects.c | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
From: Jeff King <hidden> Date: 2016-07-29 04:15:37
In the original implementation of want_object_in_pack(), we
always looked for the object in every pack, so the order did
not matter for performance.
As of the last few patches, however, we can now often break
out of the loop early after finding the first instance, and
avoid looking in the other packs at all. In this case, pack
order can make a big difference, because we'd like to find
the objects by looking at as few packs as possible.
This patch switches us to the same packed_git_mru list that
is now used by normal object lookups.
Here are timings for p5303 on linux.git:
Test HEAD^ HEAD
------------------------------------------------------------------------
5303.3: rev-list (1) 31.31(31.07+0.23) 31.28(31.00+0.27) -0.1%
5303.4: repack (1) 40.35(38.84+2.60) 40.53(39.31+2.32) +0.4%
5303.6: rev-list (50) 31.37(31.15+0.21) 31.41(31.16+0.24) +0.1%
5303.7: repack (50) 58.25(68.54+2.03) 47.28(57.66+1.89) -18.8%
5303.9: rev-list (1000) 31.91(31.57+0.33) 31.93(31.64+0.28) +0.1%
5303.10: repack (1000) 304.80(376.00+3.92) 87.21(159.54+2.84) -71.4%
The rev-list numbers are unchanged, which makes sense (they
are not exercising this code at all). The 50- and 1000-pack
repack cases show considerable improvement.
The single-pack repack case doesn't, of course; there's
nothing to improve. In fact, it gives us a baseline for how
fast we could possibly go. You can see that though rev-list
can approach the single-pack case even with 1000 packs,
repack doesn't. The reason is simple: the loop we are
optimizing is only part of what the repack is doing. After
the "counting" phase, we do delta compression, which is much
more expensive when there are multiple packs, because we
have fewer deltas we can reuse (you can also see that these
numbers come from a multicore machine; the CPU times are
much higher than the wall-clock times due to the delta
phase).
So the good news is that in cases with many packs, we used
to be dominated by the "counting" phase, and now we are
dominated by the delta compression (which is faster, and
which we have already parallelized).
Here are similar numbers for git.git:
Test HEAD^ HEAD
---------------------------------------------------------------------
5303.3: rev-list (1) 1.55(1.51+0.02) 1.54(1.53+0.00) -0.6%
5303.4: repack (1) 1.82(1.80+0.08) 1.82(1.78+0.09) +0.0%
5303.6: rev-list (50) 1.58(1.57+0.00) 1.58(1.56+0.01) +0.0%
5303.7: repack (50) 2.50(3.12+0.07) 2.31(2.95+0.06) -7.6%
5303.9: rev-list (1000) 2.22(2.20+0.02) 2.23(2.19+0.03) +0.5%
5303.10: repack (1000) 10.47(16.78+0.22) 7.50(13.76+0.22) -28.4%
Not as impressive in terms of percentage, but still
measurable wins. If you look at the wall-clock time
improvements in the 1000-pack case, you can see that linux
improved by roughly 10x as many seconds as git. That's
because it has roughly 10x as many objects, and we'd expect
this improvement to scale linearly with the number of
objects (since the number of packs is kept constant). It's
just that the "counting" phase is a smaller percentage of
the total time spent for a git.git repack, and hence the
percentage win is smaller.
The implementation itself is a straightforward use of the
MRU code. We only bother marking a pack as used when we know
that we are able to break early out of the loop, for two
reasons:
1. If we can't break out early, it does no good; we have
to visit each pack anyway, so we might as well avoid
even the minor overhead of managing the cache order.
2. The mru_mark() function reorders the list, which would
screw up our traversal. So it is only safe to mark when
we are about to break out of the loop. We could record
the found pack and mark it after the loop finishes, of
course, but that's more complicated and it doesn't buy
us anything due to (1).
Note that this reordering does have a potential impact on
the final pack, as we store only a single "found" pack for
each object, even if it is present in multiple packs. In
principle, any copy is acceptable, as they all refer to the
same content. But in practice, they may differ in whether
they are stored as deltas, against which base, etc. This may
have an impact on delta reuse, and even the delta search
(since we skip pairs that were already in the same pack).
It's not clear whether this change of order would hurt or
even help average cases, though. The most likely reason to
have duplicate objects is from the completion of thin packs
(e.g., you have some objects, then receive several pushes;
the packs you receive may be thin on the wire, with deltas
that refer to bases outside the pack, but we complete them
with duplicate base objects when indexing them).
In such a case the current code would always find the thin
duplicates (because we currently walk the packs in reverse
chronological order). With this patch, it's possible that
some of them would be found in older packs instead. But
again, it's unclear whether that is a net win or loss in
practice.
Signed-off-by: Jeff King <redacted>
---
So obviously the "unclear" at the end makes me nervous. My gut feeling
is that it will be a wash (the existing ordering was simply what
happened to occur, and was not really planned for this particular use,
so there may be some small wins and some small losses which will cancel
out). But unlike the original two optimizations, this has not been
deployed at GitHub, so I don't have any empirical data (and even if it
were, I'm not quite sure what I'd measure. I guess pack size, there's so
much noise in such a measurement I expect any change would be lost).
builtin/pack-objects.c | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
From: Jeff King <hidden> Date: 2016-07-29 05:45:44
On Fri, Jul 29, 2016 at 12:15:24AM -0400, Jeff King wrote:
Note that this reordering does have a potential impact on
the final pack, as we store only a single "found" pack for
each object, even if it is present in multiple packs. In
principle, any copy is acceptable, as they all refer to the
same content. But in practice, they may differ in whether
they are stored as deltas, against which base, etc. This may
have an impact on delta reuse, and even the delta search
(since we skip pairs that were already in the same pack).
It's not clear whether this change of order would hurt or
even help average cases, though. The most likely reason to
have duplicate objects is from the completion of thin packs
(e.g., you have some objects, then receive several pushes;
the packs you receive may be thin on the wire, with deltas
that refer to bases outside the pack, but we complete them
with duplicate base objects when indexing them).
In such a case the current code would always find the thin
duplicates (because we currently walk the packs in reverse
chronological order). With this patch, it's possible that
some of them would be found in older packs instead. But
again, it's unclear whether that is a net win or loss in
practice.
Hmm, so the efficacy of packing aside, this does definitely have a
negative effect.
I happened to have a repository sitting around that has 15 million
objects and 3600 packs (don't ask), so this seemed like a good test.
With this patch series, it took 11 minutes to do the counting, delta
compression, and write phases. Without it, after 11 minutes git had not
even gotten 10% of the way through counting. So that's good news.
The not-so-good news is that during the write phase, it hit the
"recursive delta detected" warning in write_one(), many times.
I think what is happening is that in the original code, we cannot ever
see a delta cycle, because the pack ordering is fixed. So if `A` is a
delta of `B`, then we know that they must both exist in the same pack
(since we do not do cross-pack deltas on disk). And because we look at
the packs in the same order for each object, we know that if we are
going to find `A`, we must either find `B` in the same pack (or a prior
one if there is another duplicate). But if we do so, then we cannot
also find `B` as a delta of `A` in that pack (because we know that packs
do not have delta cycles themselves) or an earlier pack (because if so,
we would have found `A` in that pack, too).
But because this series switches the order of pack-lookup between
objects, it is possible for us to find a `B` which is a delta against
`A` in one pack, and then another copy of `A` which is a delta against
another copy of `B` from another pack. We add both of the deltas to our
packing list, but at write time when we try to write out all of the
bases for `A`, we realize that whoops, we are recursing infinitely.
As it turns out, Git actually handles this pretty well! Upon noticing
the recursion, it breaks the delta chain and writes out one of the
objects as a full base. This is due to Junio's f63c79d (pack-object:
tolerate broken packs that have duplicated objects, 2011-11-16), though
I think we later decided that duplicated objects were simply insane.
So one option is to simply silence the warning, because the resulting
pack is perfectly fine. But we do notice this during the write phase,
after the delta search is done. So it's possible that the resulting pack
is not as small as it could be (i.e., we break the chain with a base
object, but it's possible if we looked that we could have broken the
chain by making a delta against an existing base object). So I wonder if
it's possible to detect this case earlier, during the "can we reuse this
delta" bits of check_object().
Suggestions welcome. I haven't really dug past what I've written here,
and it's way too late here to go any further tonight.
-Peff