Benchmarks
Results
- PutFS serves concurrent small file reads 2-3x faster than MinIO
- PutFS writes are 1.6-5x faster than MinIO
- PutFS directory listings are 10-65x faster, the gap growing with directory size
- PutFS uses 37x less memory
- PutFS reads a 1 GB file 2x faster
- MinIO wins on bulk parallel uploads (
mc mirrorvsputfs sync)
Both backends ran on the same server and the same ext4 SSD, PutFS as the Rust build behind nginx, see Test setup.
Small file random reads (10K files, 1K–100K, wrk, 30s)
| Concurrency | PutFS | MinIO | Ratio |
|---|---|---|---|
| 1 | 5,968 req/s | 3,150 req/s | 1.9x |
| 10 | 39,876 req/s | 18,158 req/s | 2.2x |
| 100 | 73,668 req/s | 23,408 req/s | 3.1x |
| 1000 | 77,693 req/s | 23,518 req/s | 3.3x |
nginx serves files via sendfile straight from the page cache; the app server is never involved, so reads are the same for every PutFS build.
Single-file write latency (hyperfine, mean)
| File size | PutFS | MinIO | Ratio |
|---|---|---|---|
| 1 KB | 8.1ms | 41.0ms | 5.0x |
| 10 KB | 9.6ms | 33.1ms | 3.5x |
| 100 KB | 7.7ms | 31.4ms | 4.1x |
| 1 MB | 8.1ms | 34.5ms | 4.3x |
| 10 MB | 38.2ms | 110.2ms | 2.9x |
| 100 MB | 323ms | 616ms | 1.9x |
| 1 GB | 3.1s | 5.1s | 1.6x |
| 10 GB | 38.7s | 123.5s | 3.2x |
Each size re-uploads one key, so every run after the first replaces an existing object. ext4 writes a file's data out when a rename replaces another file (auto_da_alloc, on by default), so from 10 MB on PutFS writes at disk speed. A new key does not pay this:
| 1 GB upload | PutFS | MinIO |
|---|---|---|
| New key (one run) | 1,060 MB/s | 81 MB/s |
| Replacing a key | 330 MB/s | 200 MB/s |
Bulk upload (10K files, 1K–100K)
| System | Method | files/s |
|---|---|---|
| MinIO | mc mirror | 1,365 |
| PutFS | putfs sync | 1,126 |
Go's native concurrency in mc mirror beats the threaded Python putfs sync.
Large file sequential read (1 GB)
| System | Time | Throughput |
|---|---|---|
| PutFS | 224ms | 4,562 MB/s |
| MinIO | 469ms | 2,183 MB/s |
PutFS is read with curl (nginx sendfile), MinIO with mc cat.
Directory listing
| Dataset size | PutFS | MinIO | Ratio |
|---|---|---|---|
| 1K objects | 6.7ms | 70ms | 10x |
| 10K objects | 13ms | 460ms | 34x |
| 100K objects | 64ms | 4,180ms | 65x |
One full listing per run, client process included (curl with QUERY vs mc ls), which is most of the 1K figure. For the server's walk alone see the wrk listing numbers below.
Memory footprint (idle)
| System | Total |
|---|---|
| PutFS | 66.5 MiB (API: 42.0 MiB + nginx: 24.5 MiB) |
| MinIO | 2,431 MiB |
PutFS uses 37x less memory. This is each container's anonymous memory (heap, stacks) from its cgroup, sampled after the run and unchanged under 100 concurrent readers.
Summary
| Metric | PutFS | MinIO | Ratio |
|---|---|---|---|
| Reads (c=1000) | 77,693 req/s | 23,518 req/s | 3.3x |
| Write 1 KB | 8.1ms | 41.0ms | 5.0x |
| Write 1 GB | 3.1s | 5.1s | 1.6x |
| Large read 1 GB | 4,562 MB/s | 2,183 MB/s | 2.1x |
| List 100K | 64ms | 4,180ms | 65x |
| Bulk upload | 1,126 files/s | 1,365 files/s | 0.8x |
| Memory idle | 66.5 MiB | 2,431 MiB | 37x |
Tuning
A Unix socket between nginx and granian cut write latency ~15–20%. See Granian tuning and Nginx tuning.
access_log
access_log /dev/stdout cut throughput from 104K req/s to 3.8K req/s in our benchmarks. Keep access_log off, see Nginx tuning.
Test setup
Both ran in docker on the same server, with their data on the same local SSD (ext4) and the benchmark clients on the host. The seed and write source files were generated on a tmpfs (TESTDATA_DIR), off that SSD.
Tuning
- PutFS:
- the Rust build (
docker-compose.rust.yml) on a unix socket - nginx, no access logs
- the Rust build (
- MinIO:
MINIO_API_REQUESTS_MAX: "0"MINIO_SCANNER_SPEED: "fastest"MINIO_BROWSER: "off"GOMAXPROCS: "0"
Hardware
| Component | Spec |
|---|---|
| CPU | Intel Core i7-8700 @ 3.20 GHz (6 cores, 12 threads) |
| RAM | 64 GB DDR4 |
| Storage | local SSD, one ext4 partition for both backends |
| Test data | tmpfs |
| OS | Debian 13 (trixie), kernel 6.12 |
Running
Scripts live in benchmark/ (README); results are read with analyze.sh, one CSV column per result directory:
The two backends run as separate commands, never in one sequence, so the disks can be wiped or re-laid between them. TESTDATA_DIR (default /tmp, about 12 GB) keeps the generated files off the backend's disk.
docker compose -f docker-compose.yml -f docker-compose.rust.yml up -d --build # PutFS: the Rust build behind nginx
make -C benchmark putfs
# tear down, wipe or re-lay the disks
docker compose -f contrib/docker-compose.minio.yml up -d
make -C benchmark minio
bash benchmark/analyze.sh benchmark/results/minio/putfs benchmark/results/minio/minio
Implementation comparison (Python / Go / Rust)
The Go and Rust ports share the Python build's HTTP API; only the language differs. This benchmark isolates the app-server hot path (PUT / DELETE / LIST; GET/HEAD are nginx's job in production) by running each server bare on localhost, no nginx, no auth, driven by wrk.
Different setup from the MinIO comparison above
Developer laptop, page cache, 4 KB objects, no fsync: these measure per-request handling overhead, not disk throughput, and are not comparable to the server numbers of the MinIO comparison. Listing uses wrk over a kept-alive connection, the server's real scandir + serialise cost, which is why it grows with object count here.
Parallel PUT throughput (4 KB objects, wrk)
| Concurrency | Python (granian, 16 workers) | Go | Rust |
|---|---|---|---|
| 1 | 1,435 req/s | 8,350 req/s | 8,506 req/s |
| 10 | 3,945 req/s | 31,255 req/s | 51,061 req/s |
| 100 | 6,479 req/s | 67,857 req/s | 35,656 req/s |
Directory listing (wrk, keep-alive)
| Objects | Python | Go | Rust |
|---|---|---|---|
| 1K | 4.2 ms · 240k keys/s | 1.3 ms · 755k keys/s | 0.83 ms · 1.26M keys/s |
| 10K | 37 ms · 268k keys/s | 9.6 ms · 1.04M keys/s | 5.4 ms · 1.84M keys/s |
| 100K | 362 ms · 265k keys/s | 73 ms · 1.36M keys/s | 37 ms · 2.71M keys/s |
- Listing: Rust wins, ~1.7–2.0× Go and ~5–10× Python, widening with directory size.
- Writes: Rust leads at c10 (~1.6× Go), Go at c100 (~1.9× Rust), c1 is a tie. Rust offloads each PUT's
openandrenameto a thread pool (as Python'sanyio.to_threaddoes), which costs it only at high fan-out. - Both ports are ~5–13× Python on writes. Python's per-line async listing tops out at ~240–270k keys/s regardless of size.
Intel Core i7-1360P (16 threads), 30 GB RAM, Debian forky/sid (kernel 7.0.10); bare servers on localhost, page cache, mean of three wrk runs. Not the server of the MinIO comparison.