revlog: use an LRU cache for delta chain bases
authorGregory Szorc <gregory.szorc@gmail.com>
Mon, 22 Aug 2016 21:48:50 -0700
changeset 29830 92ac2baaea86
parent 29829 dae97049345b
child 29831 1316c7cccc76
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.
mercurial/revlog.py
--- a/mercurial/revlog.py	Mon Aug 22 20:17:36 2016 -0700
+++ b/mercurial/revlog.py	Mon Aug 22 21:48:50 2016 -0700
@@ -225,9 +225,8 @@
         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.
@@ -340,7 +339,7 @@
 
     def clearcaches(self):
         self._cache = None
-        self._basecache = None
+        self._chainbasecache.clear()
         self._chunkcache = (0, '')
         self._pcache = {}
 
@@ -390,11 +389,17 @@
     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]
@@ -1430,10 +1435,7 @@
                     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
@@ -1448,9 +1450,6 @@
         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
@@ -1514,7 +1513,7 @@
 
         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):