Repository: borgbackup/borg

Issue:
borg benchmark command
It would be nice to have a command to benchmark different subsystems.   Something like

```
borg benchmark repo /mnt/external_drive/backup
     100MB/s up   200MB/s down
borg benchmark repo offsetitebackup.com:foo
     1MB/s up   10MB/s down
borg benchmark compression lzma,1
      2MB/s compression  8MB/s decompression
borg benchmark chunker
   ... etc
```

My current use case is picking compression settings for on-site and off-site backups, as a new user.  Today answering a question "how much compression can I run and still be IO bound" is fairly involved, takes time and knowledge to setup and run tests.   With builtin benchmarking support it could be done by a simple script in a few minutes.

Another usecase would be trouble-shooting performance issues by users/sysadmins (it is network, hard drive, code regression?).  Also, it would be useful for development, to trend performance over time.


---

:moneybag: [there is a bounty for this](https://www.bountysource.com/issues/38830536-borg-benchmark-command)

(not necessarily like described above, see also comments)



Discussion:
The question without compression is if borg is hitting a file-processing boundary or the network-speed boundary. The goal is, that on a reasonable PC and 'normal' network, borg should not be the limiting factor. If that is the case, your question can be answered by benchmarking network throughput and IO-performance. :-)

Of course, compression is the interesting thing. But that speed depends almost only on the data that is being compressed. I did some benchmarks on different data corpus here: https://github.com/dragetd/borgbench

Under compression I tested different settings on an debian root filesystem which should have a mixed bag of compressible data. E.g. (https://github.com/dragetd/borgbench/blob/master/compression/img/results_rootfs_zlib.png) or https://github.com/dragetd/borgbench/blob/master/compression/img/results_rootfs_lzma.png

So in the end, the speed should be something over 90% of max(average io performance for traversing and reading all files / network speed / compression speed based on the actual data)

see also #1618 and #1364 

add some benchmark, so we can see perf independently of user data:
- write some known content / size files to src folder, like:

  - big, all zero
  - big, all random
  - lots of small files, all zero
  - lots of small files, all random
- create backups of these into dst repo
- don't use compression (we know that lz4 is fast and lzma is slow)
- test with / without encryption depending on how dst repo was initialized


Resolving patch:
diff --git a/src/borg/archiver.py b/src/borg/archiver.py
--- a/src/borg/archiver.py
+++ b/src/borg/archiver.py
@@ -9,6 +9,7 @@
 import os
 import re
 import shlex
+import shutil
 import signal
 import stat
 import subprocess
@@ -17,6 +18,7 @@
 import time
 import traceback
 from binascii import unhexlify
+from contextlib import contextmanager
 from datetime import datetime, timedelta
 from itertools import zip_longest
 
@@ -57,7 +59,7 @@
 from .item import Item
 from .key import key_creator, tam_required_file, tam_required, RepoKey, PassphraseKey
 from .keymanager import KeyManager
-from .platform import get_flags, umount, get_process_id
+from .platform import get_flags, umount, get_process_id, SyncFile
 from .remote import RepositoryServer, RemoteRepository, cache_if_remote
 from .repository import Repository, LIST_SCAN_LIMIT
 from .selftest import selftest
@@ -322,6 +324,72 @@ def do_migrate_to_repokey(self, args, repository):
         logger.info('Key updated')
         return EXIT_SUCCESS
 
+    def do_benchmark_crud(self, args):
+        def measurement_run(repo, path):
+            archive = repo + '::borg-benchmark-crud'
+            compression = '--compression=none'
+            # measure create perf (without files cache to always have it chunking)
+            t_start = time.monotonic()
+            rc = self.do_create(self.parse_args(['create', compression, '--no-files-cache', archive + '1', path]))
+            t_end = time.monotonic()
+            dt_create = t_end - t_start
+            assert rc == 0
+            # now build files cache
+            rc1 = self.do_create(self.parse_args(['create', compression, archive + '2', path]))
+            rc2 = self.do_delete(self.parse_args(['delete', archive + '2']))
+            assert rc1 == rc2 == 0
+            # measure a no-change update (archive1 is still present)
+            t_start = time.monotonic()
+            rc1 = self.do_create(self.parse_args(['create', compression, archive + '3', path]))
+            t_end = time.monotonic()
+            dt_update = t_end - t_start
+            rc2 = self.do_delete(self.parse_args(['delete', archive + '3']))
+            assert rc1 == rc2 == 0
+            # measure extraction (dry-run: without writing result to disk)
+            t_start = time.monotonic()
+            rc = self.do_extract(self.parse_args(['extract', '--dry-run', archive + '1']))
+            t_end = time.monotonic()
+            dt_extract = t_end - t_start
+            assert rc == 0
+            # measure archive deletion (of LAST present archive with the data)
+            t_start = time.monotonic()
+            rc = self.do_delete(self.parse_args(['delete', archive + '1']))
+            t_end = time.monotonic()
+            dt_delete = t_end - t_start
+            assert rc == 0
+            return dt_create, dt_update, dt_extract, dt_delete
+
+        @contextmanager
+        def test_files(path, count, size, random):
+            path = os.path.join(path, 'borg-test-data')
+            os.makedirs(path)
+            for i in range(count):
+                fname = os.path.join(path, 'file_%d' % i)
+                data = b'\0' * size if not random else os.urandom(size)
+                with SyncFile(fname, binary=True) as fd:  # used for posix_fadvise's sake
+                    fd.write(data)
+            yield path
+            shutil.rmtree(path)
+
+        for msg, count, size, random in [
+            ('Z-BIG', 10, 100000000, False),
+            ('R-BIG', 10, 100000000, True),
+            ('Z-MEDIUM', 1000, 1000000, False),
+            ('R-MEDIUM', 1000, 1000000, True),
+            ('Z-SMALL', 10000, 10000, False),
+            ('R-SMALL', 10000, 10000, True),
+        ]:
+            with test_files(args.path, count, size, random) as path:
+                dt_create, dt_update, dt_extract, dt_delete = measurement_run(args.location.canonical_path(), path)
+            total_size_MB = count * size / 1e06
+            file_size_formatted = format_file_size(size)
+            content = 'random' if random else 'all-zero'
+            fmt = '%s-%-10s %9.2f MB/s (%d * %s %s files: %.2fs)'
+            print(fmt % ('C', msg, total_size_MB / dt_create, count, file_size_formatted, content, dt_create))
+            print(fmt % ('R', msg, total_size_MB / dt_extract, count, file_size_formatted, content, dt_extract))
+            print(fmt % ('U', msg, total_size_MB / dt_update, count, file_size_formatted, content, dt_update))
+            print(fmt % ('D', msg, total_size_MB / dt_delete, count, file_size_formatted, content, dt_delete))
+
     @with_repository(fake='dry_run', exclusive=True)
     def do_create(self, args, repository, manifest=None, key=None):
         """Create new archive"""
@@ -3141,6 +3209,69 @@ def process_epilog(epilog):
         subparser.add_argument('ids', metavar='IDs', nargs='+', type=str,
                                help='hex object ID(s) to show refcounts for')
 
+        benchmark_epilog = process_epilog("These commands do various benchmarks.")
+
+        subparser = subparsers.add_parser('benchmark', parents=[common_parser], add_help=False,
+                                          description='benchmark command',
+                                          epilog=benchmark_epilog,
+                                          formatter_class=argparse.RawDescriptionHelpFormatter,
+                                          help='benchmark command')
+
+        benchmark_parsers = subparser.add_subparsers(title='required arguments', metavar='<command>')
+        subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser))
+
+        bench_crud_epilog = process_epilog("""
+        This command benchmarks borg CRUD (create, read, update, delete) operations.
+
+        It creates input data below the given PATH and backups this data into the given REPO.
+        The REPO must already exist (it could be a fresh empty repo or an existing repo, the
+        command will create / read / update / delete some archives named borg-test-data* there.
+
+        Make sure you have free space there, you'll need about 1GB each (+ overhead).
+
+        If your repository is encrypted and borg needs a passphrase to unlock the key, use:
+
+        BORG_PASSPHRASE=mysecret borg benchmark crud REPO PATH
+
+        Measurements are done with different input file sizes and counts.
+        The file contents are very artificial (either all zero or all random),
+        thus the measurement results do not necessarily reflect performance with real data.
+        Also, due to the kind of content used, no compression is used in these benchmarks.
+
+        C- == borg create (1st archive creation, no compression, do not use files cache)
+              C-Z- == all-zero files. full dedup, this is primarily measuring reader/chunker/hasher.
+              C-R- == random files. no dedup, measuring throughput through all processing stages.
+
+        R- == borg extract (extract archive, dry-run, do everything, but do not write files to disk)
+              R-Z- == all zero files. Measuring heavily duplicated files.
+              R-R- == random files. No duplication here, measuring throughput through all processing
+                      stages, except writing to disk.
+
+        U- == borg create (2nd archive creation of unchanged input files, measure files cache speed)
+              The throughput value is kind of virtual here, it does not actually read the file.
+              U-Z- == needs to check the 2 all-zero chunks' existence in the repo.
+              U-R- == needs to check existence of a lot of different chunks in the repo.
+
+        D- == borg delete archive (delete last remaining archive, measure deletion + compaction)
+              D-Z- == few chunks to delete / few segments to compact/remove.
+              D-R- == many chunks to delete / many segments to compact/remove.
+
+        Please note that there might be quite some variance in these measurements.
+        Try multiple measurements and having a otherwise idle machine (and network, if you use it).
+        """)
+        subparser = benchmark_parsers.add_parser('crud', parents=[common_parser], add_help=False,
+                                                 description=self.do_benchmark_crud.__doc__,
+                                                 epilog=bench_crud_epilog,
+                                                 formatter_class=argparse.RawDescriptionHelpFormatter,
+                                                 help='benchmarks borg CRUD (create, extract, update, delete).')
+        subparser.set_defaults(func=self.do_benchmark_crud)
+
+        subparser.add_argument('location', metavar='REPO',
+                               type=location_validator(archive=False),
+                               help='repo to use for benchmark (must exist)')
+
+        subparser.add_argument('path', metavar='PATH', help='path were to create benchmark input data')
+
         return parser
 
     @staticmethod


Test patch: