# 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](#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](#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.com` with
  `mlkem768x25519-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 = 0` turns 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 governor `performance`, 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 in
  `bench/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
  `sftp` for SFTP, and Python's `ftplib` for 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 start
  `RETR` together. Login speed has its own rows.
- **Logins:** one login is connect, `USER`, `PASS`, `QUIT` with
  `ftplib`. 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 with `login_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 `RETR`s 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 `sendfile` on. FTPS uses an
  RSA-2048 certificate; all three negotiated TLS 1.3 with
  `TLS_AES_256_GCM_SHA384`. siftp is the release build with the
  `io-uring` feature; `io_uring` is off (the default), so the
  `sendfile` and `splice` paths served these transfers.

### Reproduce

```sh
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

```toml
[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.

```sh
cargo test  -p siftp-asm --features asm   # differential tests
cargo bench -p siftp-asm --features asm   # both versions side by side
```
