Skip to content

Granian tuning

Granian is the ASGI server running the PutFS API. The defaults work for most deployments; this page covers what matters for streaming uploads.

Note

Reads (GET/HEAD) are served by nginx via sendfile and never reach granian. Tuning granian only affects writes and listings.

Workers

--workers 4

One Python process each. Match the CPU core count, never more than 2x: the API is I/O-bound, so extra workers only add memory and context switching.

CPU cores Workers
2 2
4 4
8 4–8
16+ 8–16

Runtime mode

--runtime-mode mt

mt shares the Rust async runtime across workers and is the better fit on multi-core systems. st gives each worker its own runtime and only pays off with 1–2 workers.

Event loop

--loop uvloop

uvloop is the default. In our benchmarks it has ~20% lower per-request latency on small writes (1K–1M) than rloop; rloop's higher throughput on 10G+ sequential writes disappears behind real network I/O.

Backpressure

--backpressure 128

Maximum concurrent requests per worker. Each PUT is a long-lived connection, so this is how many uploads run at once. The default derives from backlog / workers.

Workload Backpressure
Few large uploads 16–32
Many small uploads 128–256
Mixed 128 (PutFS default)

Too high wastes memory (each in-flight request holds a buffer), too low queues requests.

HTTP/1 buffer size

--http1-buffer-size 10485760

Default is ~408KB. PutFS sets 10MB to match its streaming chunk size: fewer read syscalls per chunk.

Memory budget

The three knobs multiply. Worst case for an all-PUT workload:

workers × backpressure × (http1_buffer_size + chunk_size)

With PutFS defaults:

4 × 128 × (10 MB + 10 MB) = 10 GB

That is the ceiling, reached only if every concurrent request is mid-upload with both buffers full; steady state is far lower. On a memory-constrained host, halve GRANIAN_BACKPRESSURE first, then reduce PUTFS_CHUNK_SIZE and GRANIAN_HTTP1_BUFFER_SIZE together (they should match). Cutting GRANIAN_WORKERS below the core count costs throughput; do it last.

Protocol

--http 1 --no-ws

HTTP/1.1 only: nginx proxies via HTTP/1.1 anyway, and HTTP/2 adds framing for nothing. PutFS uses no WebSockets.

Unix socket

The Docker image listens on /run/putfs/putfs.sock:

GRANIAN_UDS=/run/putfs/putfs.sock
GRANIAN_UDS_PERMISSIONS=666

TCP instead (slower):

GRANIAN_HOST=127.0.0.1
GRANIAN_PORT=5000
# unset GRANIAN_UDS

Environment variables

Every setting is a GRANIAN_ env var. The Docker image sets these defaults:

GRANIAN_INTERFACE=asgi          # don't change
GRANIAN_UDS=/run/putfs/putfs.sock
GRANIAN_UDS_PERMISSIONS=666
GRANIAN_WORKERS=4               # match CPU cores
GRANIAN_RUNTIME_MODE=mt
GRANIAN_LOOP=uvloop
GRANIAN_BACKPRESSURE=128
GRANIAN_HTTP1_BUFFER_SIZE=10485760
GRANIAN_HTTP=1
GRANIAN_NO_WS=true

What not to change

  • --blocking-threads: WSGI only; granian errors at startup under ASGI.
  • --http 2: framing overhead, no benefit for streaming uploads.
  • --runtime-mode st: only useful with 1–2 workers.
  • --workers 1 --threads 16 on Python 3.14t: free-threaded Python is slower here, with far higher write latency and memory. The GIL is not the bottleneck; PutFS is I/O bound and the GIL releases during I/O.

Further reading