Patchwork [3,of,3] revlog: use an LRU cache for delta chain bases

login
register
mail settings
Submitter Gregory Szorc
Date Aug. 23, 2016, 4:50 a.m.
Message ID <bd07831e4af707bb43bb.1471927821@ubuntu-vm-main>
Download mbox | patch
Permalink /patch/16391/
State Accepted
Headers show

Comments

Gregory Szorc - Aug. 23, 2016, 4:50 a.m.
# HG changeset patch
# User Gregory Szorc <gregory.szorc@gmail.com>
# Date 1471927730 25200
#      Mon Aug 22 21:48:50 2016 -0700
# Node ID bd07831e4af707bb43bbe48b5a1d5d08803c26d8
# Parent  c49d98dbf6319b14162c3a4126a2a1757927e982
revlog: use an LRU cache for delta chain bases

Profiling using statprof revealed a hotspot during changegroup
application calculating delta chain bases on generaldelta repos.
Essentially, revlog._addrevision() was performing a lot of redundant
work tracing the delta chain as part of determining when the chain
distance was acceptable. This was most pronounced when adding
revisions to manifests, which can have delta chains thousands of
revisions long.

There was a delta chain base cache on revlogs before, but it only
captured a single revision. This was acceptable before generaldelta,
when _addrevision would build deltas from the previous revision and
thus we'd pretty much guarantee a cache hit when resolving the delta
chain base on a subsequent _addrevision call. However, it isn't
suitable for generaldelta because parent revisions aren't necessarily
the last processed revision.

This patch converts the delta chain base cache to an LRU dict cache.
The cache can hold multiple entries, so generaldelta repos have a
higher chance of getting a cache hit.

The impact of this change when processing changegroup additions is
significant. On a generaldelta conversion of the "mozilla-unified"
repo (which contains heads of the main Firefox repositories in
chronological order - this means there are lots of transitions between
heads in revlog order), this change has the following impact when
performing an `hg unbundle` of an uncompressed bundle of the repo:

before: 5:42 CPU time
after:  4:34 CPU time

Most of this time is saved when applying the changelog and manifest
revlogs:

before: 2:30 CPU time
after:  1:17 CPU time

That nearly a 50% reduction in CPU time applying changesets and
manifests!

Applying a gzipped bundle of the same repo (effectively simulating a
`hg clone` over HTTP) showed a similar speedup:

before: 5:53 CPU time
after:  4:46 CPU time

Wall time improvements were basically the same as CPU time.

I didn't measure explicitly, but it feels like most of the time
is saved when processing manifests. This makes sense, as large
manifests tend to have very long delta chains and thus benefit the
most from this cache.

So, this change effectively makes changegroup application (which is
used by `hg unbundle`, `hg clone`, `hg pull`, `hg unshelve`, and
various other commands) significantly faster when delta chains are
long (which can happen on repos with large numbers of files and thus
large manifests).

In theory, this change can result in more memory utilization. However,
we're caching a dict of ints. At most we have 200 ints + Python object
overhead per revlog. And, the cache is really only populated when
performing read-heavy operations, such as adding changegroups or
scanning an individual revlog. For memory bloat to be an issue, we'd
need to scan/read several revisions from several revlogs all while
having active references to several revlogs. I don't think there are
many operations that do this, so I don't think memory bloat from the
cache will be an issue.
Gregory Szorc - Aug. 23, 2016, 6 a.m.
On Mon, Aug 22, 2016 at 9:50 PM, Gregory Szorc <gregory.szorc@gmail.com>
wrote:

> # HG changeset patch
> # User Gregory Szorc <gregory.szorc@gmail.com>
> # Date 1471927730 25200
> #      Mon Aug 22 21:48:50 2016 -0700
> # Node ID bd07831e4af707bb43bbe48b5a1d5d08803c26d8
> # Parent  c49d98dbf6319b14162c3a4126a2a1757927e982
> revlog: use an LRU cache for delta chain bases
>
> Profiling using statprof revealed a hotspot during changegroup
> application calculating delta chain bases on generaldelta repos.
> Essentially, revlog._addrevision() was performing a lot of redundant
> work tracing the delta chain as part of determining when the chain
> distance was acceptable. This was most pronounced when adding
> revisions to manifests, which can have delta chains thousands of
> revisions long.
>
> There was a delta chain base cache on revlogs before, but it only
> captured a single revision. This was acceptable before generaldelta,
> when _addrevision would build deltas from the previous revision and
> thus we'd pretty much guarantee a cache hit when resolving the delta
> chain base on a subsequent _addrevision call. However, it isn't
> suitable for generaldelta because parent revisions aren't necessarily
> the last processed revision.
>
> This patch converts the delta chain base cache to an LRU dict cache.
> The cache can hold multiple entries, so generaldelta repos have a
> higher chance of getting a cache hit.
>
> The impact of this change when processing changegroup additions is
> significant. On a generaldelta conversion of the "mozilla-unified"
> repo (which contains heads of the main Firefox repositories in
> chronological order - this means there are lots of transitions between
> heads in revlog order), this change has the following impact when
> performing an `hg unbundle` of an uncompressed bundle of the repo:
>
> before: 5:42 CPU time
> after:  4:34 CPU time
>
> Most of this time is saved when applying the changelog and manifest
> revlogs:
>
> before: 2:30 CPU time
> after:  1:17 CPU time
>
> That nearly a 50% reduction in CPU time applying changesets and
> manifests!
>
> Applying a gzipped bundle of the same repo (effectively simulating a
> `hg clone` over HTTP) showed a similar speedup:
>
> before: 5:53 CPU time
> after:  4:46 CPU time
>
> Wall time improvements were basically the same as CPU time.
>
> I didn't measure explicitly, but it feels like most of the time
> is saved when processing manifests. This makes sense, as large
> manifests tend to have very long delta chains and thus benefit the
> most from this cache.
>
> So, this change effectively makes changegroup application (which is
> used by `hg unbundle`, `hg clone`, `hg pull`, `hg unshelve`, and
> various other commands) significantly faster when delta chains are
> long (which can happen on repos with large numbers of files and thus
> large manifests).
>
> In theory, this change can result in more memory utilization. However,
> we're caching a dict of ints. At most we have 200 ints + Python object
> overhead per revlog. And, the cache is really only populated when
> performing read-heavy operations, such as adding changegroups or
> scanning an individual revlog. For memory bloat to be an issue, we'd
> need to scan/read several revisions from several revlogs all while
> having active references to several revlogs. I don't think there are
> many operations that do this, so I don't think memory bloat from the
> cache will be an issue.
>
>
To follow up on this, this change shows little to no benefit on repos
without interleaved heads or with shorter delta chains. (And this makes
sense.)

Both the mozilla-central repo (which has some merges but only a single
head) and the pypy repo (which has 161 heads but only 5500 files) showed no
statistical change with this patch.

The performance optimization appears to only manifest when delta chain base
calculation can take a lot of time and that only happens if delta chains
are really long - say >10,000. This is common on the mozilla-unified repo,
which on the server is using aggressivemergedeltas and produces extremely
long delta chains (35k+) unless we limit their length with
format.maxchainlen (which we do - at 10k). However, local clients don't
apply this delta chain limit during `hg clone` and other unbundling
operations since Mercurial enforces no delta chain length limit by default
- only delta size and distance.

I'm curious if anyone else is running a repo with say 50,000+ files and
interleaved heads that could corroborate this improvement.
via Mercurial-devel - Aug. 23, 2016, 6:03 a.m.
Oh, another follow-up that I almost forgot: I've pushed this to committed.

On Mon, Aug 22, 2016, 23:00 Gregory Szorc <gregory.szorc@gmail.com> wrote:

> On Mon, Aug 22, 2016 at 9:50 PM, Gregory Szorc <gregory.szorc@gmail.com>
> wrote:
>
>> # HG changeset patch
>> # User Gregory Szorc <gregory.szorc@gmail.com>
>> # Date 1471927730 25200
>> #      Mon Aug 22 21:48:50 2016 -0700
>> # Node ID bd07831e4af707bb43bbe48b5a1d5d08803c26d8
>> # Parent  c49d98dbf6319b14162c3a4126a2a1757927e982
>> revlog: use an LRU cache for delta chain bases
>>
>> Profiling using statprof revealed a hotspot during changegroup
>> application calculating delta chain bases on generaldelta repos.
>> Essentially, revlog._addrevision() was performing a lot of redundant
>> work tracing the delta chain as part of determining when the chain
>> distance was acceptable. This was most pronounced when adding
>> revisions to manifests, which can have delta chains thousands of
>> revisions long.
>>
>> There was a delta chain base cache on revlogs before, but it only
>> captured a single revision. This was acceptable before generaldelta,
>> when _addrevision would build deltas from the previous revision and
>> thus we'd pretty much guarantee a cache hit when resolving the delta
>> chain base on a subsequent _addrevision call. However, it isn't
>> suitable for generaldelta because parent revisions aren't necessarily
>> the last processed revision.
>>
>> This patch converts the delta chain base cache to an LRU dict cache.
>> The cache can hold multiple entries, so generaldelta repos have a
>> higher chance of getting a cache hit.
>>
>> The impact of this change when processing changegroup additions is
>> significant. On a generaldelta conversion of the "mozilla-unified"
>> repo (which contains heads of the main Firefox repositories in
>> chronological order - this means there are lots of transitions between
>> heads in revlog order), this change has the following impact when
>> performing an `hg unbundle` of an uncompressed bundle of the repo:
>>
>> before: 5:42 CPU time
>> after:  4:34 CPU time
>>
>> Most of this time is saved when applying the changelog and manifest
>> revlogs:
>>
>> before: 2:30 CPU time
>> after:  1:17 CPU time
>>
>> That nearly a 50% reduction in CPU time applying changesets and
>> manifests!
>>
>> Applying a gzipped bundle of the same repo (effectively simulating a
>> `hg clone` over HTTP) showed a similar speedup:
>>
>> before: 5:53 CPU time
>> after:  4:46 CPU time
>>
>> Wall time improvements were basically the same as CPU time.
>>
>> I didn't measure explicitly, but it feels like most of the time
>> is saved when processing manifests. This makes sense, as large
>> manifests tend to have very long delta chains and thus benefit the
>> most from this cache.
>>
>> So, this change effectively makes changegroup application (which is
>> used by `hg unbundle`, `hg clone`, `hg pull`, `hg unshelve`, and
>> various other commands) significantly faster when delta chains are
>> long (which can happen on repos with large numbers of files and thus
>> large manifests).
>>
>> In theory, this change can result in more memory utilization. However,
>> we're caching a dict of ints. At most we have 200 ints + Python object
>> overhead per revlog. And, the cache is really only populated when
>> performing read-heavy operations, such as adding changegroups or
>> scanning an individual revlog. For memory bloat to be an issue, we'd
>> need to scan/read several revisions from several revlogs all while
>> having active references to several revlogs. I don't think there are
>> many operations that do this, so I don't think memory bloat from the
>> cache will be an issue.
>>
>>
> To follow up on this, this change shows little to no benefit on repos
> without interleaved heads or with shorter delta chains. (And this makes
> sense.)
>
> Both the mozilla-central repo (which has some merges but only a single
> head) and the pypy repo (which has 161 heads but only 5500 files) showed no
> statistical change with this patch.
>
> The performance optimization appears to only manifest when delta chain
> base calculation can take a lot of time and that only happens if delta
> chains are really long - say >10,000. This is common on the mozilla-unified
> repo, which on the server is using aggressivemergedeltas and produces
> extremely long delta chains (35k+) unless we limit their length with
> format.maxchainlen (which we do - at 10k). However, local clients don't
> apply this delta chain limit during `hg clone` and other unbundling
> operations since Mercurial enforces no delta chain length limit by default
> - only delta size and distance.
>
> I'm curious if anyone else is running a repo with say 50,000+ files and
> interleaved heads that could corroborate this improvement.
> _______________________________________________
> Mercurial-devel mailing list
> Mercurial-devel@mercurial-scm.org
> https://www.mercurial-scm.org/mailman/listinfo/mercurial-devel
>

Patch

diff --git a/mercurial/revlog.py b/mercurial/revlog.py
--- a/mercurial/revlog.py
+++ b/mercurial/revlog.py
@@ -220,19 +220,18 @@  class revlog(object):
         opener is a function that abstracts the file opening operation
         and can be used to implement COW semantics or the like.
         """
         self.indexfile = indexfile
         self.datafile = indexfile[:-2] + ".d"
         self.opener = opener
         # 3-tuple of (node, rev, text) for a raw revision.
         self._cache = None
-        # 2-tuple of (rev, baserev) defining the base revision the delta chain
-        # begins at for a revision.
-        self._basecache = None
+        # Maps rev to chain base rev.
+        self._chainbasecache = util.lrucachedict(100)
         # 2-tuple of (offset, data) of raw data from the revlog at an offset.
         self._chunkcache = (0, '')
         # How much data to read and cache into the raw revlog data cache.
         self._chunkcachesize = 65536
         self._maxchainlen = None
         self._aggressivemergedeltas = False
         self.index = []
         # Mapping of partial identifiers to full nodes.
@@ -335,17 +334,17 @@  class revlog(object):
         try:
             self.rev(node)
             return True
         except KeyError:
             return False
 
     def clearcaches(self):
         self._cache = None
-        self._basecache = None
+        self._chainbasecache.clear()
         self._chunkcache = (0, '')
         self._pcache = {}
 
         try:
             self._nodecache.clearcaches()
         except AttributeError:
             self._nodecache = {nullid: nullrev}
             self._nodepos = None
@@ -385,21 +384,27 @@  class revlog(object):
         return self.index[rev][5:7]
     def start(self, rev):
         return int(self.index[rev][0] >> 16)
     def end(self, rev):
         return self.start(rev) + self.length(rev)
     def length(self, rev):
         return self.index[rev][1]
     def chainbase(self, rev):
+        base = self._chainbasecache.get(rev)
+        if base is not None:
+            return base
+
         index = self.index
         base = index[rev][3]
         while base != rev:
             rev = base
             base = index[rev][3]
+
+        self._chainbasecache[rev] = base
         return base
     def chainlen(self, rev):
         return self._chaininfo(rev)[0]
 
     def _chaininfo(self, rev):
         chaininfocache = self._chaininfocache
         if rev in chaininfocache:
             return chaininfocache[rev]
@@ -1425,37 +1430,31 @@  class revlog(object):
                     if self._inline:
                         fh = ifh
                     else:
                         fh = dfh
                     ptext = self.revision(self.node(rev), _df=fh)
                     delta = mdiff.textdiff(ptext, t)
             data = self.compress(delta)
             l = len(data[1]) + len(data[0])
-            if basecache[0] == rev:
-                chainbase = basecache[1]
-            else:
-                chainbase = self.chainbase(rev)
+            chainbase = self.chainbase(rev)
             dist = l + offset - self.start(chainbase)
             if self._generaldelta:
                 base = rev
             else:
                 base = chainbase
             chainlen, compresseddeltalen = self._chaininfo(rev)
             chainlen += 1
             compresseddeltalen += l
             return dist, l, data, base, chainbase, chainlen, compresseddeltalen
 
         curr = len(self)
         prev = curr - 1
         offset = self.end(prev)
         delta = None
-        if self._basecache is None:
-            self._basecache = (prev, self.chainbase(prev))
-        basecache = self._basecache
         p1r, p2r = self.rev(p1), self.rev(p2)
 
         # full versions are inserted when the needed deltas
         # become comparable to the uncompressed text
         if text is None:
             textlen = mdiff.patchedsize(self.rawsize(cachedelta[0]),
                                         cachedelta[1])
         else:
@@ -1509,17 +1508,17 @@  class revlog(object):
         entry = self._io.packentry(e, self.node, self.version, curr)
         self._writeentry(transaction, ifh, dfh, entry, data, link, offset)
 
         if alwayscache and text is None:
             text = buildtext()
 
         if type(text) == str: # only accept immutable objects
             self._cache = (node, curr, text)
-        self._basecache = (curr, chainbase)
+        self._chainbasecache[curr] = chainbase
         return node
 
     def _writeentry(self, transaction, ifh, dfh, entry, data, link, offset):
         # Files opened in a+ mode have inconsistent behavior on various
         # platforms. Windows requires that a file positioning call be made
         # when the file handle transitions between reads and writes. See
         # 3686fa2b8eee and the mixedfilemodewrapper in windows.py. On other
         # platforms, Python or the platform itself can be buggy. Some versions