siftp

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, plaintextsiftpvsftpd 3.0.5ProFTPD 1.3.8c
Download 1 GiB, MiB/s401340193910
Upload 1 GiB, MiB/s279827592697
16 parallel downloads of 1 GiB, total MiB/s737157462574198
Small files up (1000 x 16 KiB), files/s580643594297
Small files down, files/s662648595036
Logins per second, first login, sequential11.719.010.4
Logins per second, first login, 32 concurrent clients142393106
Logins per second, repeat login, sequential73251910
Logins per second, repeat login, 32 concurrent clients97173393106
Server CPU per GiB downloaded, s0.0490.0500.051
Memory, idle, MiB16.42.710.2
Memory with 200 logged-in sessions, MiB17.165.5174.0
FTPS, explicit TLS, PROT Psiftpvsftpd 3.0.5ProFTPD 1.3.8c
Download 1 GiB, MiB/s235710401391
Upload 1 GiB, MiB/s17526831520
SFTPsiftpOpenSSH 10.0p2 sftp-server
Download 1 GiB, MiB/s954763
Upload 1 GiB, MiB/s900811
Session setup (key exchange, key auth, subsystem), ms5180

What the numbers say

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:

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

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.

PathWhenNotes
sendfileLinux, plaintext binary downloadFile to socket in the kernel
spliceLinux, plaintext binary uploadSocket 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_uringLinux, build feature io-uring and io_uring = trueUsed when sendfile or splice is off or refused
Pooled copyEverything else: TLS, ASCII mode, S3 roots, rate-limited users, macOSChunks 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.

KernelUsed for
crc32cChecksums (SSE4.2 crc32; aarch64 crc32c after a CPU feature check)
adler32Weak checksums
find_byte, find_newline, find_crlfFTP command framing
ct_eqConstant-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