Measurements¶
How gx-torrent compares with qBittorrent, Transmission, Deluge and anacrolix/torrent, what Gextto costs on a small machine, and how to repeat every number. The figures are the latest ones, not a history: a new round replaces the row it measures.
Short answer¶
Measured on 2026-11-07 on a low-power mini PC (Intel N97), in Docker, with the same torrents and limits for every client.
| Question | 1st | 2nd | 3rd | 4th | 5th | 6th | Why it matters |
|---|---|---|---|---|---|---|---|
| Fastest download with nothing in the way | gx-torrent, 285 MB/s | Deluge, 253 MB/s | qBittorrent, 240 MB/s | anacrolix, 197 MB/s | rain, 162 MB/s | Transmission, 29 MB/s | a fast disk and a fast peer |
| Download on a 100 Mbit/s line, 4 peers, TCP | anacrolix, 11.50 MB/s | gx-torrent, 11.22 MB/s | rain, 11.02 MB/s | Deluge, 10.68 MB/s | qBittorrent, 10.54 MB/s | Transmission, 8.84 MB/s | the common case at home |
| The same line, uTP only | Deluge, 11.80 MB/s | qBittorrent, 11.56 MB/s | anacrolix, 11.50 MB/s | gx-torrent, 11.16 MB/s | Transmission, 8.25 MB/s | rain, no uTP | peers that prefer uTP |
| Smallest peak own memory while downloading at full speed | anacrolix, 11 MiB | Transmission, 14 MiB | rain, 22 MiB | gx-torrent, 24 MiB | qBittorrent, 26 MiB | Deluge, 53 MiB | what the client cannot give back to the system |
| Smallest peak own memory while downloading on a 100 Mbit/s line | qBittorrent, 10 MiB | anacrolix, 12 MiB | Transmission, 13 MiB | rain, 30 MiB | gx-torrent, 30 MiB | Deluge, 39 MiB | the same, when the line is the limit |
| Fastest upload | qBittorrent, 285 MB/s | gx-torrent, 223 MB/s | Deluge, 203 MB/s | anacrolix, 120 MB/s | rain, 120 MB/s | Transmission, 16 MB/s | only matters above a gigabit link |
| Fastest recheck of 4 GiB | gx-torrent, 3.8 s | Transmission, 3.9 s | qBittorrent, 4.3 s | Deluge, 4.3 s | anacrolix, 6.6 s | rain, 10.2 s | after a move or a crash |
| Lowest CPU at rest, nothing loaded | rain, 0.01 % | anacrolix, 0.01 % | Transmission, 0.04 % | qBittorrent, 0.05 % | gx-torrent, 0.06 % | Deluge, 0.28 % | a machine that is always on |
| Lowest CPU at rest, 100 torrents | anacrolix, 0.01 % | qBittorrent, 0.06 % | Transmission, 0.09 % | Deluge, 0.31 % | gx-torrent, 0.66 % | rain, 2.35 % | the same, with a long list |
| Lightest with 100 torrents | Transmission, 12 MB RSS | rain, 24 MB RSS | anacrolix, 25 MB RSS | gx-torrent, 29 MB RSS | Deluge, 68 MB RSS | qBittorrent, 118 MB RSS | a long seeding list |
The order is the order of the medians: where the ranges of two clients overlap the positions are a tie, and a gap of a few percent is inside the spread of the rounds. The detailed tables below give the ranges. gx-torrent is in bold.
Own memory is the anonymous memory the client really holds, without the file pages that RSS also counts; the 100-torrent row is the only one that uses RSS. The full-speed row is the 4 GiB download from one peer, the 100 Mbit/s row the 512 MiB download from four peers shaped to that line.
In short: gx-torrent is still the fastest over TCP (285 MB/s), the fastest at a recheck (3.8 s) and third on upload (223 MB/s), behind the libtorrent clients. The library row keeps the two rows it took last round: anacrolix/torrent is the lightest at full speed (11 MiB against 24) and the quietest with a hundred torrents (0.01 % of a core), and on the shaped line over TCP it is 2 % ahead — inside the spread, so the two are level; there gx-torrent spends the least CPU per GiB (18.8 s against 21–29). On uTP the libtorrent clients are 3–6 % ahead. rain joins at 162 MB/s and uploads at 120, is level with the best at rest (0.01 %), and pays for its caches: 667 MiB of own memory while uploading and 2.35 % of a core with a hundred torrents. Transmission 3.00 is the lightest with a hundred torrents but seeds 4 GiB at 16 MB/s.
What is best for me¶
One line per need, with the client that answers it and why. Every figure behind these lines is in this document.
| Your need | Pick | Why |
|---|---|---|
| A library, not a downloader: episodes, gaps, renames, upgrades, archive | Gextto | it decides what to fetch and where it goes; none of the other clients does |
| One torrent from a browser, on a NAS, a server or a phone, with a qBittorrent-style API | gx-torrent, standalone | the same engine with its own page and /api/v2, no library to configure |
| The fastest download over TCP | gx-torrent | 285 MB/s, against 29–253 for the others |
| A recheck of a 4 GiB file without a long wait | gx-torrent | 3.7 s, against 4.0–6.9 for the others |
| Seeding on a fast LAN (gigabit and above) | gx-torrent, qBittorrent or Deluge | 244–258 MB/s, three within 6 %, at the lowest CPU per GiB for gx-torrent (2.22 s against 2.38–2.58) |
| A call or a game while something downloads | Gextto, Auto with latency source both |
the guard leaves room for the other flow and stops taking more as soon as the router sees it |
| The smallest memory at full speed | anacrolix/torrent, 11 MiB against 14–56 | the lightest client when the disk is the limit, but its own engine is not a library manager |
| The quietest client at rest | anacrolix/torrent or rain | 0.01 % of a core against 0.04–0.28, but rain spends 2.35 % with a hundred torrents and anacrolix 0.01 |
| Ratio farming, a seedbox, no library | qBittorrent or Deluge | Gextto is a library: it seeds with ratio, day and idle rules and has super-seeding, which lowers the upload by design, but the on-demand share_mode is not planned |
| Windows | gx-torrent, standalone | native service, amd64 and arm64; Gextto itself is Unix |
In short: a client that downloads and stops there is gx-torrent, the fastest of the six over TCP (285 MB/s against 29–253) and the fastest at a recheck (3.8 s against 3.9–10.2); a client that names, upgrades and keeps the files is Gextto, which is the only one that does it — and carries gx-torrent inside.
Compared with qBittorrent, Transmission, Deluge, rain and anacrolix/torrent¶
What was run¶
Every client runs in its own container with the same limits, against the same reference peers, on an isolated network. Each table is the median of three interleaved rounds, with the range in brackets where it matters. Every downloaded file was compared byte by byte with the original.
| Client | Version | Engine |
|---|---|---|
| gx-torrent | commit 2205ae8d, image fd79b59f |
gx-core, pure Go |
| qBittorrent | 5.1.0 (Debian 13) | libtorrent |
| Transmission | 3.00 (Debian 12) | its own, with libutp |
| Deluge | 2.2.0 (Debian 13) | libtorrent 2.0.11 |
| rain | v2.4.5 (github.com/cenkalti/rain/v2) |
its own, pure Go |
| anacrolix/torrent | v1.61.0, driven by scripts/compare/anacrolix |
its own, pure Go |
| Item | Value |
|---|---|
| Machine | Intel N97 (4 cores), 15 GiB RAM, Lexar NM620 NVMe, ext4, Debian 13, kernel 7.1.13, Docker 29.9.3 |
| Client under test | CPUs 2–3, 4 GiB of memory limit |
| Reference peers | qBittorrent on CPUs 0–1, 1.5 GiB each, plaintext |
| Network | Docker bridge with no outside access; a private tracker; no DHT, PEX, LSD, UPnP or NAT-PMP |
| Host | ffmpeg stopped, five quiet minutes before the first container; a run with more than 1 core busy outside the test is thrown away and repeated |
Two kinds of memory appear below. Own memory (anonymous) is what the client really holds. RSS also counts file pages mapped from disk, which the system takes back whenever it needs them; that is why libtorrent clients and anacrolix show gigabytes on a 4 GiB file without any memory pressure.
At rest¶
One client, no torrents, 60 s after a 15 s settle.
| Client | CPU (% of a core) | Own memory | RSS |
|---|---|---|---|
| rain | 0.01 | 4.4 MiB | 16 MiB |
| anacrolix | 0.01 | 6.5 MiB | 20 MiB |
| Transmission | 0.04 | 1.8 MiB | 11 MiB |
| qBittorrent | 0.05 | 7.1 MiB | 35 MiB |
| gx-torrent | 0.06 | 7.6 MiB | 21 MiB |
| Deluge | 0.28 | 38 MiB | 62 MiB |
Download at full speed¶
A 4 GiB file from one reference peer over TCP, with no rate limit and a cold disk cache: the ceiling of each client on this machine.
| Client | Speed | Time | CPU per GiB | Peak own memory | Peak RSS |
|---|---|---|---|---|---|
| gx-torrent | 285 MB/s (285–290) | 15.1 s | 3.10 s | 24 MiB | 37 MiB |
| Deluge | 253 MB/s (250–257) | 17.0 s | 2.87 s | 53 MiB | 3,985 MiB |
| qBittorrent | 240 MB/s (237–241) | 17.9 s | 2.73 s | 26 MiB | 3,989 MiB |
| anacrolix | 197 MB/s (196–201) | 21.8 s | 4.84 s | 11 MiB | 3,403 MiB |
| rain | 162 MB/s (150–162) | 26.5 s | 4.34 s | 22 MiB | 34 MiB |
| Transmission | 29 MB/s (29–30) | 146.1 s | 3.60 s | 14 MiB | 24 MiB |
Transmission 3.00 is four to ten times slower than the others here; the number repeats in every round.
Download on a 100 Mbit/s line¶
A 512 MiB file from four peers, each shaped to 25 Mbit/s and 40 ms: a line of 12.5 MB/s before protocol overhead.
| Client | Speed | Time | CPU (% of a core) | CPU per GiB | Peak own memory | Peak RSS |
|---|---|---|---|---|---|---|
| anacrolix | 11.50 MB/s | 46.7 s | 29 % | 27.4 s | 12 MiB | 524 MiB |
| gx-torrent | 11.22 MB/s | 47.8 s | 20 % | 18.8 s | 30 MiB | 45 MiB |
| rain | 11.02 MB/s | 48.7 s | 22 % | 21.0 s | 30 MiB | 41 MiB |
| Deluge | 10.68 MB/s | 50.3 s | 29 % | 29.0 s | 39 MiB | 575 MiB |
| qBittorrent | 10.54 MB/s | 50.9 s | 27 % | 27.7 s | 10 MiB | 548 MiB |
| Transmission | 8.84 MB/s | 60.7 s | 13 % | 15.8 s | 13 MiB | 23 MiB |
The first five are within 9 % of each other; gx-torrent and rain use the least CPU per GiB of the four that reach the line.
The same line, uTP only¶
The same four peers with TCP blocked at the peer.
| Client | Speed | Time | CPU (% of a core) | CPU per GiB | Peak own memory | Peak RSS |
|---|---|---|---|---|---|---|
| Deluge | 11.80 MB/s | 45.5 s | 44 % | 40.0 s | 39 MiB | 571 MiB |
| qBittorrent | 11.56 MB/s | 46.4 s | 41 % | 38.5 s | 10 MiB | 548 MiB |
| anacrolix | 11.50 MB/s | 46.7 s | 72 % | 67.3 s | 13 MiB | 528 MiB |
| gx-torrent | 11.16 MB/s | 48.1 s | 39 % | 37.1 s | 35 MiB | 50 MiB |
| Transmission | 8.25 MB/s | 65.1 s | 19 % | 24.0 s | 10 MiB | 20 MiB |
| rain | no uTP | — | — | — | — | — |
rain has no uTP transport at all, so this scenario has nothing for it to connect to and its row stays empty. The four that reach the line are within 6 % of each other; anacrolix pays twice the CPU per GiB of the libtorrent clients on this path.
Recheck and upload¶
Both start from the same 4 GiB file with the disk cache dropped: first an explicit recheck, then an upload to one unshaped reference peer.
| Client | Recheck | CPU | Upload speed | CPU per GiB | Peak own memory | Peak RSS |
|---|---|---|---|---|---|---|
| gx-torrent | 3.8 s (1,138 MB/s) | 3.3 s | 223 MB/s (204–242) | 2.32 s | 22 MiB | 37 MiB |
| Transmission | 3.9 s (1,098 MB/s) | 2.5 s | 15.9 MB/s | 5.59 s | 17 MiB | 27 MiB |
| qBittorrent | 4.3 s (1,009 MB/s) | 3.3 s | 285 MB/s (206–322) | 2.48 s | 13 MiB | 4,105 MiB |
| Deluge | 4.3 s (990 MB/s) | 3.3 s | 203 MB/s (193–285) | 2.96 s | 43 MiB | 4,098 MiB |
| anacrolix | 6.6 s (648 MB/s) | 9.3 s | 120 MB/s (119–125) | 5.30 s | 15 MiB | 4,072 MiB |
| rain | 10.2 s (421 MB/s) | 3.6 s | 120 MB/s (115–125) | 4.78 s | 667 MiB | 679 MiB |
gx-torrent and Transmission are level at the recheck (3.8 s against 3.9), ahead of the others. On upload qBittorrent and gx-torrent are within 4 % and Deluge is 9 % behind them; rain and anacrolix are at half the speed, and rain pays for it with its caches: 667 MiB of own memory against 13–43 for the others. Transmission 3.00 sends 4 GiB in four and a half minutes.
100 torrents¶
A hundred torrents of 2 MiB each, with their files already in place: time to add them, time until all are seeding, then 60 s at rest and the time to come back after a restart.
| Client | Add | All seeding | Restart | CPU at rest | Own memory | RSS |
|---|---|---|---|---|---|---|
| anacrolix | 1.0 s | 1.1 s | 0.5 s | 0.01 % | 10 MiB | 25 MiB |
| qBittorrent | 0.9 s | 100.4 s | 0.5 s | 0.06 % | 90 MiB | 118 MiB |
| Transmission | 0.9 s | 11.7 s | 0.6 s | 0.09 % | 2.9 MiB | 12 MiB |
| Deluge | 2.6 s | 2.6 s | 1.0 s | 0.31 % | 42 MiB | 68 MiB |
| gx-torrent | 1.5 s | 2.1 s | 0.5 s | 0.66 % | 14 MiB | 29 MiB |
| rain | 1.5 s | 2.5 s | 2.3 s | 2.35 % | 12 MiB | 24 MiB |
Transmission reaches seeding at once because it trusts existing files until asked to recheck; anacrolix and rain hash them, but a hundred files of 2 MiB cost a second or two. qBittorrent takes a hundred seconds to verify them. rain spends 2.35 % of a core at rest with these torrents — four times gx-torrent — and takes 2.3 s to restart.
Why the anacrolix row is so light¶
The library's row is a different trade, not a better engine: it holds almost nothing, and it pays for that in CPU. A few choices explain the 11 MiB of own memory at full speed against gx-torrent's 24, and the 0.01 % of a core at rest against 0.06:
- Writes go into a file mapping.
storage.NewFile(client.go) serves the pieces throughstorage/file-io-mmap.go: a received block is copied into the shared mapping, with no staging buffer and no write cache. gx-core writes through a buffer taken from a pool, keeps a write cache (1 GiB target) and a read cache (256 MiB default, 8 MiB floor in the daemon). That is more memory, and it is why gx-torrent uploads at 223 MB/s and 2.32 s of CPU per GiB against 120 MB/s and 5.30. - No read cache. An upload reads back from the mapping, so the client reserves nothing for it; the mapped pages show in RSS (3.2 GB on a 4 GiB file) and not in own memory.
- One cap on unverified bytes.
MaxUnverifiedBytes(64 MiB,config.go) limits the data received and not yet hashed and written, for the whole client. gx-core bounds a piece and a torrent, not the total. - Small units. 16 KiB chunks (
global.go) and one buffer per request (peerconn.go), against the 128 KiB blocks of our read cache and 1–2 MiB pieces. - Fewer wakeups. No statistics tick, no automatic speed probe, no DLNA, LSD or SSE keepalive.
What gx-core keeps in exchange: the fastest download (3.13 s of CPU per GiB against 4.96), the cheapest upload (223 MB/s against 120) and the cheaper uTP path (37 s per GiB against 67).
What these numbers do not say¶
- No ranking on a real swarm. These runs use few peers, no encryption from the peers and no discovery. Behaviour among hundreds of peers, choking included, is not measured here.
- Settings are defaults, with three exceptions the setup needs: discovery off everywhere, a fixed peer port, and Transmission's uTP off in the TCP scenarios (it otherwise waits on uTP against a TCP-only peer).
- Transmission is 3.00 from Debian 12, not the 4.1.0 beta of Debian 13: that beta receives
the peers from the tracker but never opens a uTP connection to them, so its uTP row was
always empty. The pinned image is in
scripts/compare/Dockerfile. - anacrolix/torrent is a library, not a client: its row is the library with its own
defaults, driven by the small HTTP front end in
scripts/compare/anacrolixthat adds, rechecks and reports the torrents. It has no page, no queue and no library. - Small differences are not results. A gap inside the ranges above means nothing.
- Deluge's web front end runs in a separate container, so its CPU and memory are not counted.
How to repeat the comparison¶
make measure-compare # about three hours; the machine must be otherwise idle
python3 scripts/compare/run.py report --tag <tag>
The rule is in AGENTS.md, Build
and test: same torrents (generated from a fixed formula, info hashes pinned in
scripts/compare/run.py), same pinned images (scripts/compare/Dockerfile), same limits,
quiet host, three interleaved rounds. scripts/compare/nightly.sh is the unattended version:
it waits for the machine to be quiet, smoke-tests the clients it does not trust yet, measures
HEAD in a temporary worktree when the checkout has uncommitted changes, writes the tables and
cleans up. Results are written to gextto-data/bench/compare.jsonl. Changing the machine, a
version, a limit or a payload starts a new baseline: run every client again and replace every
table above.
Gextto and gx-torrent on their own¶
Same N97 machine, outside Docker unless stated. Every figure is the latest round on this machine.
Footprint¶
| What | At rest | Under load |
|---|---|---|
| gx-torrent 1.1.331, live (DHT on, full IP filter, 3 seeds) | 0.3–0.5 % of a core, 28–30 MB RSS (16–18 MB anon) | 18–37 % of a core at 8.2–8.8 MB/s with 116–132 peers, 2.0–4.3 s of CPU per 100 MB |
| gx-torrent, 10 torrents to a NAS | about 507 MB outside the two caches, 1.1 GB peak with the NFS write cache | |
| gexttod 1.2.2260, same host | 0.4 % of a core, 45 MB anon (70 MB RSS) just started; 97 MB anon (123 MB RSS) after a full search | peak 168 MB anon during a full cycle of 47,342 releases (series and movies), about 69 s of CPU, and back to the figure at rest; a series-only cycle of ~38,800 releases peaks at 149–157 MB anon and leaves 85 MB |
At-rest figures: three samples of 2, 5 and 10 minutes for gx-torrent (DHT on, full
IP filter, three seeds, nothing transferring) and 90 s for gexttod with the integrity check off
and no transfer. The
gexttod row is the latest full cycle and the latest series-only cycle, with
GOGC=50, transparent huge pages off for the process and
GODEBUG=madvdontneed=1.
- After a burst the daemon returns its piece buffers: with 7 torrents and no transfer the RSS falls from 550 MB to 35–40 MB.
- The free heap comes back to the OS. The same series cycle before the change peaked at 237 MB anon and left 150–190 MB resident for hours; now it peaks at 149–157 MB anon and settles at 85 MB anon (168 MB and 97 MB for a full cycle of 47,342 releases). The explicit release after a cycle or a completed transfer returns 12–92 MB per call, and the 15-minute housekeeping call releases what the background workers free afterwards. Go does not compact, so the spans still pinned by live objects stay.
- A cycle allocates 1.37–1.43 GB instead of 2.01 GB (~38,800 releases;
the spread is the varying size of the feed and indexer replies). The deduplication, the
blocklist and the kind filter work in place, the combined feed and indexer batches are
sized once, the XML reader no longer translates namespaces, the Torznab error check
skips a body without the word, and the indexarr pages share one reused buffer instead
of allocating a new one per page. What remains is the XML tokenizer inside
encoding/xml(about 170 MB of internal copies per cycle; 7 % of the CPU) and the HTML parser. - Archive writes and gap queries are now a small part of a cycle: the identities of the releases just read are probed against the archive's two unique indexes and their rows skipped, and the priority gap pass searches a season once instead of once per episode. A series cycle of ~38,900 releases fell from 39–43 s of CPU to 29 s on the same host The probe covers the batch, not the archive: 1,000 releases a large archive already holds cost 9.3 ms against 19.3 ms with no probe at all, where reading the whole archive first cost 65 ms and grew with it (50,000 rows).
systemctl statusshows a large number that is mostly page cache, not the process;anonis the real figure (85 MB after a series search, 97 MB after a full one on AMemoryMaxon the unit would count the file pages, so it must leave room for them.- At rest gexttod opens no directory and writes nothing to the NAS, so its disks can spin down.
- Where the CPU goes under load (five public ISOs): about 50 % syscalls and scheduler wake-ups, 9–10 % MSE encryption, 5–7 % piece hashing, 2–3 % TLS of web seeds, 9 % the piece picker with about 200 peers.
Name parsing¶
The quality/language reader runs on every release name and on every filename the library
touches. On this machine, BenchmarkParseQuality over five typical names:
95 µs against 696 µs before the word, separator and episode scans replaced the regexes
(7.3×), and 15.0 KB / 208 allocations against 21.6 KB / 361. The release-title matchers
skip the ten anchored regexes for a name with no season marker, and the SxxExx, date and
pack shapes are read without them.
In a cycle the regexp share of the CPU fell from about 39 % to 11 % (profiles of
ParseQuality itself is now about 2 % of the sampled cycle. SQLite and the
feed/HTML parsing are the larger costs.
The web page¶
The gx-torrent main page, rendered in German on this machine: 0.78 ms and 141 KB allocated per page, 0.01 ms per small fragment, with the translations already in place. Translating the finished HTML afterwards cost about 2 ms and a tokenizer pass per page.
Peer cap¶
CPU follows established peers (about 0.1 % of a core each) far more than bytes, when the line is the limit. The daemon sizes the cap from the machine: 55 per torrent and 300 in all on this host. With nine public torrents, two interleaved rounds: 36 % fewer peers and 12 % less CPU than a fixed 55 per torrent, for 4 % fewer bytes, inside the ±10 % spread between rounds.
Real swarms and the home line¶
| What | Result | Command |
|---|---|---|
| Ubuntu ISO on an FTTC 100/15 line | 9.3–9.4 MiB/s, libtorrent 6.3–7.1 MiB/s on the same swarm; one campaign, so not a ranking | make measure-realswarm |
| Sharing the line (latency guard) | alone 8,503 KiB/s kept; another device downloading gets 55–66 % more | TestMeasureLatencyGuard |
| Seeding a fresh Ubuntu release (137 seeds, 8 leechers), inbound port closed | 0 bytes uploaded by gx-torrent and qBittorrent, 4 alternated rounds of 3 minutes; qBittorrent held 6–11 leechers, gx-torrent (10 seeding dials) 0–1 | by hand: scripts/gx-core-test.sh next to qBittorrent 5.2.3 |
| Public DHT, 5 minutes | 389 nodes, 35.1 % of queries answered, no concentrated subnet | make measure-dht |
| Real libtorrent leecher, loopback | the whole 50 MiB file in 4.8 s | make measure-choking |
| Request queue at 50 ms RTT | 303 KiB/s with one block in flight, 6,743 KiB/s with the default queue | make measure-pipeline |
| Local swarm, 3 leechers | 3.00× the file uploaded; with leechers meshed the seed drops to 1.84× | make measure-seeding |
| Indexers, 30 titles | 110 s, 205 requests, 0 failed, 5,935 releases | TestMeasureManagerLanes |
The real-swarm figure comes from one campaign.
Reusing local data¶
A torrent sharing files with one already on disk fetches only what is missing. Real Debian
(756 MiB) and Arch (1.5 GiB) ISOs, source 75 % complete, one source piece in four corrupted:
25.0 % from the network, every piece verified, in 1.31 s and 2.71 s. The result is the
same after a restart, when the sibling is added later, and with a real libtorrent seeder. With
nothing to reuse the filter costs 265 ns over 32 torrents. make measure-merge3.
Many files in one torrent¶
Over loopback from a second daemon (TestMeasureManyFilesDownload), CPU and memory of both
daemons together:
| Files | Size | Download | Average | CPU | Peak RSS |
|---|---|---|---|---|---|
| 1,000 | 0.15 GiB | 1.9 s | 85 MB/s | 0.65 core | 100 MiB |
| 6,000 | 0.92 GiB | 7.9 s | 125 MB/s | 0.88 core | 114 MiB |
| 6,500 | 3.72 GiB | 23.8 s | 168 MB/s | 1.37 cores | 121 MiB |
Each file costs one descriptor while the torrent runs, none after a stop. Checking 20,000 small files on disk takes 406 ms.
Synology DS420j (a trial, not a supported target)¶
Realtek RTD1296 (4 × Cortex-A53), 987 MB RAM, HDD RAID, about 370 MB available before the
trial, GOMEMLIMIT=250MiB.
| Phase | gexttod RSS | gx-torrent RSS | Lowest available memory | Peak iowait |
|---|---|---|---|---|
| Idle, IP filter loaded | 37–66 MB | 20–21 MB | 318 MB | 7 % |
| Search of 4 titles, 1,703 releases | 66 MB | 129 MB | 240 MB | 22 % |
| 6 large torrents, 2 minutes | 37 MB | 209 MB | 160 MB | 48 % |
| 4 torrents, 8 minutes, 8–9 MB/s | 38 MB | 90 MB | 290 MB | 28 % |
Seeding, post-processing and several days of operation were not measured.
The numbers behind engine decisions¶
A selection of the Go benchmarks: make bench-all runs all of them (six runs,
GOMAXPROCS=2) and writes the raw log and a benchstat table to gextto-data/bench/. Round
Compare numbers only within one round: the absolute values follow the machine's
state more than the code.
| Path | Cost | What it means |
|---|---|---|
Disk write, buffered / mmap (BenchmarkStorageWrite…) |
5.5–5.6 / 7.0–7.2 GB/s | far above any network speed; mmap is opt-in |
| 16 KiB writes on NFS, batched durability | 81 MiB/s (35 without) | one fsync per piece removed |
| Recheck of 4 GiB, cold, gx-torrent 1.1.296 | 3.8–4.0 s, about 4 s of CPU | reading and hashing side by side, read-ahead on |
| Upload to one peer, cold, gx-torrent 1.1.296 | 180–240 MB/s, 3.2 s of CPU per GiB | read-ahead on, next block loaded in the background |
| uTP download on a 100 Mbit/s line, gx-torrent 1.1.296 | 11.2 MB/s, 48 s of CPU per GiB | state packets batched over 2 ms |
| Piece check, v1 / v2, 4 MiB | 2.6 / 2.1 GB/s | one hash per piece |
uTP over loopback (BenchmarkTransferLoopback) |
178–188 MB/s | single connection |
| uTP over loopback, 16 KiB writes | 11.2 µs of CPU per MB (3.7 s of system time for ~730 MB); throughput 126 MB/s | one segmented sendmsg per write (UDP GSO); the wall time is round-trip bound |
| Picker, rarest pick with 150 peers | 8.6 µs | one pick in a dense swarm |
| Peer departure, 27,250 pieces | 94–105 µs | touches only the pieces the peer held |
| Refill of 250 requests over TCP | 248–256 µs, about 5 writes instead of 250 | small messages batched |
| Read cache hit | 355–393 ns, no allocation | every uploaded block |
| Read cache miss, 16 KiB block | 675 ns and 168 B | the buffer of an evicted block is read into again; the remaining bytes are the benchmark's own key |
| Webseed, 8 files, 200 ms per request, 4 ranges | 0.8 s | one host serves four ranges at once; GX_MEASURE=1 go test ./cmd/gx-torrent/ -run MeasureWebseed -v |
state.json save, 100 / 1,000 / 5,000 torrents |
0.5 / 4.3–5.0 / 20–25 ms | held under the daemon lock |
| IP filter load, 172,000 rules | 30–36 ms, 6.6 MB | at start |
| Blocked-IP lookup | 20 ns | every incoming address |
| Archive insert, 1,000 releases | 43–44 ms | per feed batch |
| Archive search, 100,000 releases | 7.9–8.1 ms | standalone search |
| Re-score of 2,000 torrents | 55–57 ms | one transaction per 500 rows |
| Series match, 500 series | 122–138 µs | one release against the list |
| Prometheus scrape | 194–204 µs | only when scraped |
Seed mode. A torrent added in seed mode hashes a piece when a peer first asks for it. The first block waits 0.19 ms at 256 KiB, 0.68 ms at 1 MiB, 3.4 ms at 4 MiB and 12.8 ms at 16 MiB per piece (file in page cache; a cold read adds one piece of disk time). At most four checks run at once per torrent.
Build, tests and CI¶
| What | Time |
|---|---|
make build from a cold Go cache |
42.5 s |
make fast, one package changed / nothing changed |
7.1 s / 1.1 s |
make gx-torrent |
0.5 s |
make test, root package in 4 processes |
22–24 s (35 s with the test binary rebuilt, 15 s warm) |
make test-serial |
34 s |
go test -count=1 ./... from a cold cache |
117 s |
make test-ui (Playwright, Chromium) |
63–72 s |
CI (ci.yml / continuous.yml / codeql.yml), 2 vCPU runners |
411 s / 278 s / 210 s |
The suite has 3,010 Go tests in 72 packages, 79 Playwright tests and 89 Go benchmarks (count build times, test times and CI times of the same rounds).
Verified without a benchmark¶
- Accessibility. 14 axe-core tests pass and the gx-torrent pages had 0 violations in light
and dark themes. Method in
ACCESSIBILITY.md. - Backup and restore. Round trip on a copy of the live data: all 40 tables of the four databases identical, integrity ok. A missing download or temp folder after a restore is a warning you can fix from the interface.
- eD2k. The EC client passes against a real
amuled2.3.3, and aMule 3.1.0 in Docker completed a download started from the page. Two samples of 30 series put real fallback coverage at 7–13 % with at least 3 sources, too low to justify an own transfer engine: Kad stays a manual search,ed2k_fallbackstays off. Reopen on a much larger sample or a material change in metadata. - Gap fill on eD2k. A series with 37 gaps across two seasons needed 2 season queries of about a minute each, returning 35 usable copies; one query per episode would have been 37 waits.
- Upgrade decisions on the live library. 0 of 1,861 archived episodes differ from a rescore
or would re-download themselves. 259 feed releases replayed in a dry run took 1.46 s and
would replace 2 episodes.
ffprobeon a 1.9 GB file takes 274 ms cold, 81 ms warm. - Media integrity. The full decode runs near real time (62 minutes of 4K in about 73
minutes); the quick 4K check reads the file and decodes only keyframes, 4 m 09 s for a
62.5-minute 5 GB episode, no false errors, and it can miss a small defect between two
keyframes. A later pass costs one
statper unchanged file. - Robustness. Lenient Torznab decoding costs nothing measurable, a short uTP header is refused instead of panicking, and the mmap fault guard turns a fault into an I/O error at the same write cost.
Closed designs¶
Closed with a measurement, so they are not retried without new data.
| Idea | Why it was closed | Reopen when |
|---|---|---|
A bounded list instead of sync.Pool for piece buffers |
no change in the harness peak (26–28 MiB at full speed either way): three buffers in use with one peer, the pool held nothing idle that mattered | |
| A memory limit or a lower GOGC while downloading | at the download peak the next collection target is within 10 % of the live heap (40 against 36 MiB on the line): the memory is live piece buffers, not headroom | |
io_uring / AIO |
the disk does 5.5–7.2 GB/s, far above any network; needs cgo or a dependency | storage slower than the network |
| Write-back cache in the engine | the storage keeps up with the download | storage slower than the network |
Batched uTP datagrams (recvmmsg/sendmmsg) |
8.3 % of samples on real swarms; saves under 1 % of a core at the live rate | a uTP-only session above 8 MB/s |
| Coalescing contiguous writes | the page cache already merges them | |
Rarity buckets, pickFileEdge cursor |
no measurement justified the cost | |
PieceDownloader bitmask |
18 KB and 15 allocations per piece: about 450 KB/s of garbage at 100 MB/s | |
Writing through a file mapping (-mmap, the library's way) |
same own memory at full speed (24.3 MiB against 24.2) with 12 % more CPU per GiB (3.28 s against 2.93), and with 100 torrents own memory rises (13.4 against 11.7); only RSS moves, to 3.9 GB of mapped pages | a storage where a write syscall costs more than a page fault |
| Skipping the latency probe when nothing transfers | the at-rest figure does not move (0.05 % against 0.06, inside the 0.03–0.06 range): the one-second probe is not what the idle CPU is made of | a profile showing where the idle wakeups go |
Async state.json |
4 ms every few minutes at 1,000 torrents, 0.003 % of a core | thousands of torrents with frequent changes, or a visible UI stall |
| Lock-free read cache | the lock costs about 0.2 % of a core at 100 MB/s of upload | |
| One PEX loop per torrent | 0.0025 % of a core and 2–3 MB at 500 peers | |
Multi-path get_peers |
peers_confirmed_multi is 0 in real runs |
|
| Anti-Sybil cap on by default | the table is not concentrated, and CGNAT subnets would be capped | |
| Peer turnover (closing the slowest peers at the cap) | on the Ubuntu ISO swarm the engine never filled the cap: peaks of 13–17 peers at 20, 5–6 at 8, against a default of 55, so turnover never acted | a real swarm that keeps a torrent at its cap |
| Auto cutting the caps when the router sees another device | a call (5 pings/s) next to a download filling the line: median 15 ms idle, 36–50 ms loaded, 2–6 of 300 lost; the spikes above 150 ms were there with Gextto idle too, from another device on the line | a call that degrades next to a download |
| Throttling TCP while uTP peers share the line (libtorrent's mixed mode) | Ubuntu 26.10, 3-minute rounds: mixed 5.9 and 7.7 MB/s, TCP only 3.9 and 6.7, uTP only 2.0 and 3.4; in the clean round mixed also had the lowest p90/p99 ping (67/163 ms against 79/850 for TCP only) | a mixed swarm where mixed loses speed or latency against one transport |
| Choking changes | reciprocation works locally and against libtorrent | a gap on a real swarm |
| Super-seeding by default | no upload saved on measurable swarms, about 24 % slower locally | |
| Merge candidates ordered by reusable bytes | superseded by the dedup | |
| Upsert with two conflict targets (archive) | 17 ms more per 1,000 releases | |
idx_archive_title, index on lower(magnet_hash) |
never used by the queries | |
| Latency guard with a fixed ceiling or floor | locked to a slow sample or an arbitrary number | |
| A third-party DHT library | not a measurement but a model: ours shares the UDP socket with uTP and serves every torrent from one channel |
Postponed: qBittorrent's renameFile/renameFolder (needs rename support in the engine) and
its setAutoManagement tuning.
Adding a measurement¶
- Compare clients only with
make measure-compare, and replace all the tables. - Compare two versions of our code with
-countandbenchstat, or interleaved runs. A single pass on a public swarm says a feature works, not which value is better. - Measure the system as it really runs, on a quiet host, with queues and auto-management on.
- Write the machine, date and command next to a number, and replace the old row.
- Keep this page readable. A number goes here only if a reader can decide something from
it; the rest stays in
gextto-data/bench/and in the command that produces it. A rejected idea goes under Closed designs.
The other harnesses are in the Makefile (measure-*, bench-all, bench-all-docker) and in
scripts/measure-baseline.sh. Profilers are opt-in on loopback: GX_TORRENT_PPROF=127.0.0.1:6061
and GEXTTO_PPROF=127.0.0.1:6062.