Skip to content

extract of a single large item from a remote repo is quadratic in its chunk count (list.pop(0) in RemoteRepository.call_many) #10450

Closed

Description

@hendriks73

Note: I have created this issue with the help of Claude Opus 5.5. All observations and diagnostics are its, not mine. I understand that there is a lot of AI trash these days. So feel free to investigate or ignore. In any case, I hope it's useful for others. We have worked around it by using larger chunks.

Thanks.

Here's what Claude found:

Have you checked borgbackup docs, FAQ, and open GitHub issues?

Yes. #8075 had a similar symptom (a slow extract from a remote repo), but was traced to a kernel/ext4 issue. This one is CPU-bound in borg.

Is this a BUG / ISSUE report or a QUESTION?

Bug (performance).

System information

  • borg client: 1.4.4 (Ubuntu 26.04 package), Python 3.14.4, x86_64
  • borg server: 1.4.4 (borg-1.4 serve on a Hetzner Storage Box), accessed via ssh://
  • The repository is encrypted with repokey-blake2 and compressed with lz4.

Describe the problem you're observing

We back up a database as a single stdin item:

mariadb-backup --backup --stream=mbstream | borg create --chunker-params buzhash,14,18,16,4095 --stdin-name beatunesdb.mbstream ::db-... -

The item is 1.22 TB (505 GB stored) in about 12.3 M chunks.

  • borg extract --stdout ::db-... beatunesdb.mbstream from a local copy of such a repository takes 47 minutes.
  • The same extract from the remote repository runs at about 6 MB/s. The borg client sits at 100 % of one core, and the whole extract takes about 17 hours.

perf record on the client shows about 99 % of the samples in a single libc function (memmove). The code path is RemoteRepository.call_many (src/borg/remote.py on 1.4-maint):

  • calls = list(calls), then calls.pop(0) for every chunk (l. 839, 929/931). For get_many(item.chunks, is_preloaded=True) that list holds all 12 M chunk ids.
  • self.preload_ids.pop(0) for every chunk (l. 942). preload() appends all chunk ids of the item (self.preload_ids += ids).
  • waiting_for.pop(0) (l. 854). That list is bounded by MAX_INFLIGHT, so it doesn't matter here.

Each pop(0) shifts the rest of the list, so the extract of one item costs O(n²) in its number of chunks. A micro-benchmark (CPython 3.14, list of ints):

list length list.pop(0)
1 M 0.26 ms
4 M 1.0 ms
12 M 2.7 ms

collections.deque.popleft() on 12 M items takes 0.09 µs. With two such pops per chunk at 12 M chunks, this adds up to many hours. That matches what we see, and it gets faster towards the end of the extract as the lists shrink.

This probably also affects borg mount, borg check --verify-data and other preloading paths for large items. We haven't measured those.

Suggested fix

Use collections.deque for calls and self.preload_ids (and waiting_for), with popleft() instead of pop(0). The calls[0] peek and += / extend work the same on a deque. self.chunkid_to_msgids[chunkid].pop(0) operates on short per-chunk lists and is fine.

As a workaround we switched to larger chunks (buzhash,17,21,19,4095, ~512 KiB), which cuts the chunk count and the quadratic cost.

Can you reproduce the problem?

Yes, every time, with any single item of several million chunks in a remote (ssh://) repository, e.g. a large stdin item created with small chunker parameters.

Activity

  1. ThomasWaldmann commented on Sep 28, 2026

    @ThomasWaldmann
    Member

    Thanks for the issue report, it sounds like a good analysis.

    You have an extreme case there:

    A very large single item AND a rather small (~64KB) chunk size, which amplifies the problem.

    So guess your workaround helps for this performance issue at the cost of slightly(?) worse deduplication. It will still produce a large amount of chunks though, so make sure you have enough RAM to deal with the overall chunks amount in that repository.

    The proposed fix sounds good. An alternatve slightly "hackish" solution might be reversing the list, so that we do not pop from the front, but from the back (avoiding the memmove).

  2. added this to the 1.4.6 milestone on Sep 28, 2026
  3. ThomasWaldmann commented on Sep 28, 2026

    @ThomasWaldmann
    Member

    Besides extreme cases like the above, this might well have a performance impact also on less extreme cases, too.

  4. ThomasWaldmann commented on Sep 28, 2026

    @ThomasWaldmann
    Member

    Issue reproduced - it is quadratic. Implementing a fix for 1.4.6 release...

    This is an important fix, for borg 1.x and also for borg 2 (where it affects transfers from borg 1.x repositories).

  5. added 2 commits that reference this issue on Sep 28, 2026
    94d840a
    e4bd387
  6. ThomasWaldmann commented on Sep 28, 2026

    @ThomasWaldmann
    Member

    1.2-maint fix: ...
    1.4-maint fix: #10454
    master fix: #10456

  7. ThomasWaldmann commented on Sep 28, 2026

    @ThomasWaldmann
    Member

    I reproduced this and measured the proposed collections.deque fix.

    Setup: Ryzen 5 8500GE (Zen 4, 16 MB L3), Debian 13, Python 3.13. The repo is repokey-blake2 with lz4 and holds one stdin item of N unique 4 KiB chunks (--chunker-params fixed,4096). The data compresses to almost nothing, so these numbers show the per-chunk cost, not I/O. "Remote" is ssh://__testsuite__/... (a local borg serve over pipes, no ssh or network), so the times are pure client CPU.

    borg 1.4: borg extract --stdout ::a big

    chunks local remote, 1.4-maint remote, deque fix
    250 k 2.7 s 9.4 s 4.2 s
    500 k 5.1 s 28.2 s 8.2 s
    1 M 10.2 s 115.0 s 15.6 s
    2 M 20.0 s 862.3 s 31.3 s
    4 M 40.2 s – 62.0 s
    12.3 M 122.9 s – (8+ h extrapolated) 190.8 s

    Without the fix, the extra time grows 3.9×, 5.0× and 8.4× per doubling of the chunk count. That is quadratic, and worse once the lists no longer fit in the L3 cache and every pop(0) memmove goes to DRAM. At 2 M chunks the unpatched client already drops to about 9.5 MB/s, close to the 6 MB/s reported here. With the deques, remote extract is linear at about 15.5 µs per chunk, and your 12.3 M-chunk case takes about 3 minutes on this box. Peak memory does not change.

    borg 1.2: same test, 1 M chunks

    local 10.0 s, remote 1.2-maint 112.7 s, remote with deque fix 15.4 s.

    master (borg 2)

    borg2's own repository access (borgstore) has no such lists, so extract/mount/check on borg2 repos are not affected. The only remaining calls.pop(0) is in the legacy remote client. It is reached by borg transfer --from-borg1 --chunker-params from a remote borg 1.x repo, which is quadratic too, though milder because there is no preload list there:

    chunks local SRC remote SRC, master remote SRC, deque fix
    250 k 10.5 s 14.9 s 11.8 s
    500 k 21.3 s 34.3 s 23.9 s
    1 M 43.2 s 88.6 s 48.7 s
    2 M 87.4 s 271.4 s 97.3 s
    4 M 175.9 s 1878.8 s 192.7 s

    PRs

    Reversing the lists instead of using deques would work for calls. It would not work well for preload_ids, though: preload() appends ids for every item, so keeping that list reversed means prepending each time, which becomes quadratic again with many small items. So all PRs use deques.

  8. hendriks73 commented on Sep 28, 2026

    @hendriks73
    Author

    Gee, you're quick, @ThomasWaldmann ! Thanks for fixing this!

  9. added 2 commits that reference this issue on Sep 28, 2026
    d01db17
    5694ae8
  10. added 2 commits that reference this issue on Sep 28, 2026
    4897f70
    09b2969
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions