Performance
How siftp moves bytes, and how it compares with vsftpd, ProFTPD, and OpenSSH's sftp-server on the same machine.
Benchmark
Measured 2026-10-16 with bench/run.sh on a bare-metal Linux host (see Method). All four servers ran one at a time in the same Debian 13 container, on loopback, with files on tmpfs. Each number is the median of 5 runs; the full results with min and max are in bench/results/amd64.json in the repository.
| FTP, plaintext | siftp | vsftpd 3.0.5 | ProFTPD 1.3.8c |
|---|---|---|---|
| Download 1 GiB, MiB/s | 4013 | 4019 | 3910 |
| Upload 1 GiB, MiB/s | 2798 | 2759 | 2697 |
| 16 parallel downloads of 1 GiB, total MiB/s | 73715 | 74625 | 74198 |
| Small files up (1000 x 16 KiB), files/s | 5806 | 4359 | 4297 |
| Small files down, files/s | 6626 | 4859 | 5036 |
| Logins per second, first login, sequential | 11.7 | 19.0 | 10.4 |
| Logins per second, first login, 32 concurrent clients | 142 | 393 | 106 |
| Logins per second, repeat login, sequential | 7325 | 19 | 10 |
| Logins per second, repeat login, 32 concurrent clients | 97173 | 393 | 106 |
| Server CPU per GiB downloaded, s | 0.049 | 0.050 | 0.051 |
| Memory, idle, MiB | 16.4 | 2.7 | 10.2 |
| Memory with 200 logged-in sessions, MiB | 17.1 | 65.5 | 174.0 |
| FTPS, explicit TLS, PROT P | siftp | vsftpd 3.0.5 | ProFTPD 1.3.8c |
|---|---|---|---|
| Download 1 GiB, MiB/s | 2357 | 1040 | 1391 |
| Upload 1 GiB, MiB/s | 1752 | 683 | 1520 |
| SFTP | siftp | OpenSSH 10.0p2 sftp-server |
|---|---|---|
| Download 1 GiB, MiB/s | 954 | 763 |
| Upload 1 GiB, MiB/s | 900 | 811 |
| Session setup (key exchange, key auth, subsystem), ms | 51 | 80 |
What the numbers say
- Plaintext FTP: large files move at about the same speed on all three, within 4%. Every server downloads with
sendfile, so the kernel does the work, and the client and loopback set the limit. Sixteen parallel streams reach about 72 GiB/s on every server, which is the host's limit, not the servers'. With 1000 small files, siftp is about a third faster than both in each direction. - FTPS: siftp downloads at 2.3x vsftpd and 1.7x ProFTPD, and uploads at 2.6x vsftpd and 1.15x ProFTPD. TLS rules out
sendfile, so these transfers use the buffer pool, with file reads and writes overlapping the socket work (see Data path). - SFTP: siftp downloads 25% faster and uploads 11% faster than OpenSSH's sftp-server, and sets up a session in 51 ms against 80. Both negotiated
chacha20-poly1305@openssh.comwithmlkem768x25519-sha256. - Many sessions: siftp runs every session in one process, so 200 logged-in sessions add under 1 MiB. vsftpd and ProFTPD fork one process per session. siftp's idle figure is higher than theirs because the runtime starts one worker thread per CPU (96 on this host), and because it is measured after the login tests, so it includes heap the allocator kept after about 600,000 logins.
- First logins: each one runs argon2id (64 MiB, 3 passes), which is deliberately expensive, so siftp checks fewer new passwords per second than vsftpd, which checks the system's yescrypt hashes (
$y$in/etc/shadow). With 32 clients, siftp completes 142 checks per second, each taking 2.6x as long as one check alone. Unknown user names cost siftp the same argon2id work, so login timing does not reveal which accounts exist. - Repeat logins: a credential that argon2id has accepted is served from siftp's verification cache for
login_cache_ttl_secs(default 300 s) without recomputing the hash: 7325 logins per second from one client, 97173 from 32, limited by connection handling. vsftpd and ProFTPD hash every time, so their repeat and first rows are equal.
Login throughput
A password check costs what argon2id costs, and that is the point: a copy of the user store costs an attacker the same per guess. On the benchmark host one login takes about 85 ms, and 32 at once complete about 140 per second, each taking 2.6x as long as one alone, with most of the 96 vCPUs idle. Memory bandwidth sets that ceiling, not the implementation: on an earlier Apple silicon test VM, Debian's reference C library took twice as long per lane as the Rust implementation siftp uses. A faster kernel would shorten a single login, not raise the ceiling.
siftp keeps logins fast without making hashes cheaper:
- Verification cache. After argon2id accepts a password, the server keeps a BLAKE2b keyed digest of the user name, the stored hash, and the password under a 256-bit key drawn from the OS at start. The key is never written anywhere, so neither the user store nor the cache contents help an attacker, and only credentials that passed are ever stored: a wrong password misses the cache and pays a full argon2id check every time. A changed password misses because the stored hash is part of the digest, and the cache is cleared whenever the user table changes.
login_cache_ttl_secs = 0turns it off. - Concurrency. Checks run on all cores.
max_concurrent_password_checks(default one per CPU) caps how many run at once, so a burst of logins queues instead of allocating 64 MiB per connection. - Keys. SSH public-key logins do not involve argon2id at all; the 51 ms SFTP session setup is key exchange and authentication.
If you need more distinct first logins per second than that on similar hardware, add cores and memory bandwidth, or lengthen the cache TTL. siftp accepts argon2id hashes down to the OWASP minimum (19 MiB, 2 passes), which costs about a third of the default, but siftpd hash-password only produces default-cost hashes: a cheaper hash makes a stolen user store exactly that much cheaper to attack, and that is not a trade the server will make for you.
Method
- Host: AWS
c7i.metal-24xl: Intel Xeon (Sapphire Rapids), 96 vCPUs, 188 GiB, bare metal with no hypervisor. Ubuntu 24.04 with kernel 7.0, CPU frequency governorperformance, nothing else running. The five runs behind each siftp number fell within 5% of each other; the widest spread for another server was 10% (vsftpd's FTPS download). An earlier run in Docker Desktop's VM on an Apple silicon Mac, before the current FTPS data path, is inbench/results/arm64.json. Run the harness on your own hardware before sizing a deployment. - Storage and network: tmpfs and loopback. This measures each server's data path rather than a disk or a NIC.
- Clients: curl 8.14.1 for large FTP and FTPS transfers, OpenSSH's
sftpfor SFTP, and Python'sftplibfor small files, logins, parallel downloads, and sessions. The same client is used against every server. - Throughput counts only the data phase. For curl, that is
time_total - time_pretransfer. For SFTP, it is wall-clock time minus the median time of a session that transfers nothing (the setup row). Parallel sessions all log in first, wait at a barrier, then startRETRtogether. Login speed has its own rows. - Logins: one login is connect,
USER,PASS,QUITwithftplib. Sequential runs at least 100 logins and at least 3 s; concurrent runs 32 processes for 6 s after a barrier. First-login rows run siftp withlogin_cache_ttl_secs = 0, so every login hashes. vsftpd and ProFTPD have no cache, so their first and repeat rows are the same measurement. - CPU: the sum of user and system time across the server's process tree during eight 1 GiB
RETRs on one logged-in session. - Memory: PSS summed across the server's process tree, idle and then with 200 sessions logged in and idle.
- Configuration: each server has one local user with a home directory; vsftpd and ProFTPD have
sendfileon. FTPS uses an RSA-2048 certificate; all three negotiated TLS 1.3 withTLS_AES_256_GCM_SHA384. siftp is the release build with theio-uringfeature;io_uringis off (the default), so thesendfileandsplicepaths served these transfers.
Reproduce
bench/run.sh # full run, about 10 minutes; needs Docker
bench/run.sh --quick # smaller files, 3 runs
The script builds siftpd for Linux on your machine's architecture and runs bench/bench.py in a Debian container that has vsftpd, ProFTPD, and OpenSSH from the distribution. It writes bench/results/<arch>.json and prints the tables above.
Data path
Each transfer takes the fastest path that is correct for it. The GET /api/v1/stats and /metrics counters show which path served each transfer.
| Path | When | Notes |
|---|---|---|
sendfile | Linux, plaintext binary download | File to socket in the kernel |
splice | Linux, plaintext binary upload | Socket to pipe to file, with the pipe grown to 1 MiB where the kernel allows; the file is created only after the first splice works |
io_uring | Linux, build feature io-uring and io_uring = true | Used when sendfile or splice is off or refused |
| Pooled copy | Everything else: TLS, ASCII mode, S3 roots, rate-limited users, macOS | Chunks from a shared buffer pool with a fixed byte budget. Socket and file work overlap: an upload fills a whole chunk before one file write, which runs while the next chunk fills; a download reads the next chunk while the current one is sent, when the pool has a spare buffer |
If a kernel path is unavailable (for example, seccomp blocks io_uring under Docker's default profile), the transfer falls back to the pooled copy before any byte moves. Clients see no difference.
When every pooled buffer is in use, new chunks wait for one to be returned. That wait is how backpressure works, and it caps transfer memory at buffer_pool_bytes however many sessions are active.
Settings
[performance]
sendfile = true # Linux downloads
splice = true # Linux uploads
io_uring = false # needs the io-uring build feature
buffer_pool_bytes = 67108864 # total memory for pooled transfers
transfer_chunk_bytes = 262144 # size of one pooled chunk
Raise buffer_pool_bytes if many concurrent TLS or S3 transfers wait on the pool. siftp_buffer_pool_waits_total in /metrics counts those waits.
Assembly kernels
siftp-asm has hand-written x86_64 and aarch64 versions of the hot byte loops, each next to a pure-Rust version that defines the expected result.
| Kernel | Used for |
|---|---|
crc32c | Checksums (SSE4.2 crc32; aarch64 crc32c after a CPU feature check) |
adler32 | Weak checksums |
find_byte, find_newline, find_crlf | FTP command framing |
ct_eq | Constant-time token comparison |
Differential tests compare each kernel with its Rust version on every build. --no-default-features builds without assembly and gives the same results.
cargo test -p siftp-asm --features asm # differential tests
cargo bench -p siftp-asm --features asm # both versions side by side