Documentation

Performance

Claims about speed are worthless without the commands behind them, so here are both. The headline: a cold install through peryx costs about what going straight to pypi.org costs, and a warm one is bounded by the installer's own CPU, not the network. This page measures that against every PyPI cache you could run instead. For why peryx behaves this way, see performance and methodology.

The measurement

The workload installs pandas and polars (six wheels, about 64 MB, including one 47 MB wheel) into a fresh virtualenv with a fresh installer cache, so every byte must come through the index:

uv venv fresh-venv
env VIRTUAL_ENV=$PWD/fresh-venv UV_CACHE_DIR=$PWD/fresh-cache \
    UV_INDEX_URL=http://127.0.0.1:4433/root/pypi/simple/ \
    uv pip install pandas polars

Setup: peryx release build and the client on the same Apple Silicon laptop, roughly 700 Mbit/s to PyPI's CDN. Each cell is the median over several independent rounds, each restarting the server on empty state; "cold" is that empty first pass, "warm" reruns against the now-full cache. See performance and methodology for how the rounds, spread, and network-bound rows are handled.

ScenarioWall timeWhat dominates
uv direct to pypi.org0.94–1.03 sthe network, end to end
through peryx, cold cache1.13–1.38 sthe network; peryx adds ~0.1–0.3 s
through peryx, warm cache0.66–0.71 suv itself (0.76 s of CPU unzipping and installing)

Per-request server timings from the warm runs: simple pages and cached wheels serve in 0 ms; the largest page in the set (numpy's, 2.6 MB of JSON) transforms in under 30 ms on its first warm hit and is a memory copy afterwards.

The run-to-run spread on the cold numbers is the CDN, not peryx: the same 47 MB wheel arrived in anything from 0.7 to 1.3 s across runs. And a laptop next to its cache is the least favorable setup for the warm numbers: the farther your machines sit from PyPI (CI in a private subnet, an office behind one uplink), the more the warm path wins, because it replaces your worst network hop instead of a loopback.

The field

The tables below put peryx next to every alternative that starts hermetically from a package, plus direct, meaning uv talking to pypi.org with no proxy in between, the baseline every ratio compares against. The servers overlap on features. They diverge on two things the benchmarks price: where the bytes go on a cache miss, and what happens when many clients miss the same thing at once. The rest of this section reads each server's source for those two axes, so the tables that follow are readable in advance rather than in hindsight.

ServerStackOn a missPersisted cachePrivate uploads
peryxone static Rust binary, async (tokio/axum), one processstreams the bytes through, teeing into the storecontent-addressed, on disk (redb + blobs)token per index
devpiPython/Pyramid on waitress (~50 threads); primary + replicapages: fetch, parse, store, render; files: stream and teeSQLite keyfs plus sha256-addressed filesper-user, per-ACL
proxpiPython/Flask under gunicorn (4 worker processes here)download to a disk temp dir in a thread; client waitsindex in RAM (per worker), files on disknone
pypiserverPython/Bottle, serves a directory of files302 redirect to pypi.org, caching nothingnone for upstream contenthtpasswd on a dir
pypicloudPython/Pyramid on waitress (8 threads); archived since 2023buffer the whole file to a temp file, store it, then serveSQLite (or S3/GCS/DB) plus named-path filesuser/group access

Where the bytes go on a miss

The cold rows come down to how each server moves an uncached wheel from pypi.org to the client.

client requests an uncached wheel

peryx, devpi:
stream to the client while
teeing into a content-addressed store

proxpi:
download to a disk temp dir in a thread;
client waits, or is redirected after 0.9 s

pypiserver:
302 redirect to pypi.org;
nothing is downloaded or cached

pypicloud:
buffer the whole file to a temp file,
write to store + DB, then serve

client requests an uncached wheel

peryx, devpi:
stream to the client while
teeing into a content-addressed store

proxpi:
download to a disk temp dir in a thread;
client waits, or is redirected after 0.9 s

pypiserver:
302 redirect to pypi.org;
nothing is downloaded or cached

pypicloud:
buffer the whole file to a temp file,
write to store + DB, then serve

  • peryx never buffers a whole response. Page and artifact bytes stream to the client and into the store at once; peryx transforms a page chunk by chunk mid-flight, and tees a wheel to a temp file, hashes it, and renames it into the store once the client already has its bytes. A miss costs upstream wire time plus one hop. That sets the cold-install and cold-throughput numbers.
  • devpi handles artifacts much as peryx does. FileStreamer writes each chunk to a local file and yields it to the client, then commits the sha256-addressed file once the body completes. Simple pages take the slower route: devpi fetches the upstream page, parses it, writes the link list into its SQLite keyfs, and only then renders a response from its own store. On PyPI-sized pages that parse-and-store step is real work on every refresh, and it runs under a single-writer transaction model.
  • proxpi downloads a missed file to disk in a background thread while the requesting client blocks on thread.join(0.9 s); if the download outruns that PROXPI_DOWNLOAD_TIMEOUT, proxpi redirects the client to pypi.org and lets the thread finish caching for next time. Its file cache defaults to a tempfile.mkdtemp() that gets deleted on shutdown, so without a configured PROXPI_CACHE_DIR the cache does not survive a restart. proxpi serves cached files from disk via send_file, not from an in-memory blob. The resident memory in the resource rows comes from four gunicorn worker processes, each holding its own unshared in-RAM index cache.
  • pypiserver serves a directory of your own packages; with --fallback-url a miss is a bare 302 redirect to pypi.org's simple page. It downloads and caches nothing. That is why its CPU sits near zero and its cold and warm columns barely move: there is no cache to warm, and a miss is a formatted redirect string.
  • pypicloud was the closest design to peryx (a fallback = cache read-through mirror), but its cold path fully buffers. It pulls the entire upstream file into a TemporaryFile, computes hashes, writes it to storage and a row into its cache DB, and only then sets the response body. The client waits for the download, the disk write, and the DB commit before its first byte. pypicloud stores files by name/version/filename, not by hash. The project has been archived since 2023 and runs only under Python 3.10 with SQLAlchemy pinned below 2.

Only peryx serves PEP 658 .metadata by default (and synthesizes it with byte-range reads when an upstream lacks it); proxpi proxies it when the upstream advertises it, devpi hides it behind an experimental --enable-core-metadata, and pypiserver and pypicloud do not serve it at all. This drives the warm-resolution numbers: a resolver comparing ten versions fetches kilobytes from peryx and megabytes of wheels from the servers that cannot offer the sibling.

What a concurrent cold burst does

The cold rows of the parallel-install and throughput tables turn on one question: what does a server do when several clients miss the same uncached thing at once? peryx answers with single-flight, where concurrent misses for one page or file share a single upstream fetch and all tail its result. Two competitors answer with a failure the source explains.

devpi, the empty first page. On the first concurrent fetch of a project, a request that loses the internal name-list lock evaluates the project against an as-yet-empty project list, concludes it does not exist, returns a 404, and caches that negative result for the mirror-expiry window (30 minutes by default). uv reads the 404 as "no such package" and the install fails.

devpiclients B…Jclient Adevpiclients B…Jclient Auv reads 404 as "no such package"GET simple/polars/ (first ever)GET simple/polars/ (name list still empty)404 "does not exist" (cached ~30 min)200 once the upstream fetch lands
devpiclients B…Jclient Adevpiclients B…Jclient Auv reads 404 as "no such package"GET simple/polars/ (first ever)GET simple/polars/ (name list still empty)404 "does not exist" (cached ~30 min)200 once the upstream fetch lands

pypicloud, the concurrent INSERT. The cache-on-miss path has no dedup and no locking. Four clients asking for one wheel each download the whole file, then each try to write the same filename primary key into single-writer SQLite. The commits serialize; the losers hit a UNIQUE constraint (or database is locked), and because pyramid_tm commits after the view returns with no retry configured, the exception surfaces as HTTP 500.

SQLitepypicloud4 clients (cold)SQLitepypicloud4 clients (cold)no dedup, each clientdownloads the whole fileGET the same wheel ×4INSERT the same filename ×42nd–4th: UNIQUE constraint / database is lockedHTTP 500
SQLitepypicloud4 clients (cold)SQLitepypicloud4 clients (cold)no dedup, each clientdownloads the whole fileGET the same wheel ×4INSERT the same filename ×42nd–4th: UNIQUE constraint / database is lockedHTTP 500

Read this way, each table below is a controlled test of one axis: cold latency, warm overhead, a concurrent cold burst, a fleet installing at once, a swarm reading pages. The architecture above says in advance which servers should struggle where.

The benchmark suite

The tables below come from a benchmark harness the repository carries as a Rust crate: it builds peryx, starts every competitor from its published package, times the same workload through each with a native HTTP client, samples each server's process tree while its workload runs, and writes one TOML report these tables render from. Cells tint from best-in-row green to worst-in-row red; the ratio in parentheses compares against direct, the no-proxy baseline, so each server's cell reads as the overhead (or win) it adds over talking to pypi.org yourself.

The table covers every alternative that can be started hermetically from a published package: peryx, devpi, proxpi, pypiserver (whose upstream fallback is a redirect rather than a cache), and pypicloud (archived upstream; it still runs, but only under Python 3.10 with SQLAlchemy pinned below 2). Pulp needs PostgreSQL plus four services, nginx_pypi_cache is a Docker configuration rather than a package, and Artifactory, Nexus, and the cloud registries need licenses or accounts, so none of them can be measured this way.

The install workload is the top 51 most-downloaded PyPI packages (the snapshot, torch included for one large wheel), installed with uv into a fresh virtualenv with a fresh client cache. Cold is the first install against a server with empty state; warm reruns it with the server's cache full and only the client reset.

uv: install the top 51 PyPI packages (ratios vs direct)
median over the run's rounds, ± coefficient of variation; a net row is upstream-bound and not a peryx measurement
peryx direct devpi proxpi pypiserver pypicloud
cold cache net 33.4 s ±29% (9.26x) 3.6 s ±7% (1.00x) 12.7 s ±5% (3.50x) 4.4 s ±12% (1.23x) 3.9 s ±2% (1.07x) 9.8 s ±5% (2.72x)
warm cache 8.4 s ±30% (2.37x) 3.5 s ±4% (1.00x) 6.5 s ±4% (1.84x) 3.7 s ±12% (1.04x) 3.9 s ±18% (1.10x) 4.7 s ±3% (1.32x)
server CPU 40.0 s ±16% (1.00x) no server (n/a) 16.1 s ±2% (0.40x) 3.5 s ±9% (0.09x) 46 ms ±9% (0.00x) 3.4 s ±3% (0.09x)
server peak memory 570 MB ±11% (1.00x) no server (n/a) 1,179 MB ±9% (2.07x) 639 MB ±1% (1.12x) 68 MB ±3% (0.12x) 367 MB ±8% (0.64x)

The same workload through pip tells a different story: pip installs serially and does its own work between requests, so the client dominates and every server lands within a few seconds of the rest. A faster index cannot rescue a slow client; through uv, the index is what you feel.

pip: install the top 51 PyPI packages (ratios vs direct)
median over the run's rounds, ± coefficient of variation; a net row is upstream-bound and not a peryx measurement
peryx direct devpi proxpi pypiserver pypicloud
cold cache net 1m 06.5s ±3% (3.42x) 19.4 s ±1% (1.00x) 26.7 s ±1% (1.37x) 22.5 s ±10% (1.16x) 20.0 s ±1% (1.03x) 24.8 s ±2% (1.27x)
warm cache 30.6 s ±20% (1.57x) 19.5 s ±1% (1.00x) 18.7 s ±0% (0.96x) 21.0 s ±10% (1.08x) 19.9 s ±1% (1.02x) 16.8 s ±2% (0.86x)
server CPU 1m 08.8s ±6% (1.00x) no server (n/a) 12.3 s ±1% (0.18x) 6.0 s ±8% (0.09x) 35 ms ±11% (0.00x) 2.5 s ±10% (0.04x)
server peak memory 449 MB ±15% (1.00x) no server (n/a) 1,056 MB ±6% (2.35x) 544 MB ±10% (1.21x) 67 MB ±3% (0.15x) 359 MB ±4% (0.80x)

The throughput workload moves one large wheel (torch, ~88 MB). The cold row is the moment a CI fleet fears: four clients ask for the same wheel the instant a release lands, and the server either fans one upstream transfer out to every waiter or serializes them. peryx runs the transfer as a detached task every client tails, so all four see their first byte in milliseconds and finish together in the time one download takes; pypicloud answers the same burst with HTTP 500. The hot rows measure how fast a cached wheel leaves the server, alone and under eight parallel readers. Every number past ~3 GB/s outruns a 25 GbE link, so those cells compare server efficiency, not anything a client on a network would feel.

moving one large wheel (torch): cold under contention, hot at speed (ratios vs direct)
median over the run's rounds, ± coefficient of variation; a net row is upstream-bound and not a peryx measurement
peryx direct devpi proxpi pypiserver pypicloud
cold cache: 4 clients, one wheel net 819 ms ±1% (0.27x) 3.0 s ±5% (1.00x) 3.1 s ±0% (1.01x) 6.1 s ±0% (2.02x) 3.0 s ±0% (1.00x) error (n/a)
hot cache: single download 4,306 MB/s ±6% (36.97x) 116 MB/s ±0% (1.00x) 957 MB/s ±40% (8.21x) 3,203 MB/s ±51% (27.50x) 116 MB/s ±0% (1.00x) error (n/a)
hot cache: 8 parallel downloads 7,653 MB/s ±4% (65.51x) 117 MB/s ±0% (1.00x) 2,951 MB/s ±17% (25.26x) 9,024 MB/s ±16% (77.24x) 117 MB/s ±0% (1.00x) error (n/a)
server CPU 294 ms ±13% (1.00x) no server (n/a) 1.5 s ±4% (5.17x) 744 ms ±17% (2.53x) 0 ms ±173% (0.00x) 565 ms ±5% (1.92x)
server peak memory 44 MB ±4% (1.00x) no server (n/a) 675 MB ±35% (15.23x) 290 MB ±1% (6.55x) 67 MB ±0% (1.50x) 260 MB ±22% (5.85x)

The parallel-install workload is that fleet end to end: ten virtualenvs install polars at once, each with its own empty client cache, exactly like ten CI jobs landing on the same runner pool. The server sees ten simultaneous copies of every page and wheel request. This is where correctness under concurrency shows up next to speed: devpi fails eight of the ten cold installs, because concurrent requests for a project it is fetching for the first time see an empty page and uv concludes the package does not exist.

uv: ten venvs install polars at once (ratios vs direct)
median over the run's rounds, ± coefficient of variation; a net row is upstream-bound and not a peryx measurement
peryx direct devpi proxpi pypiserver pypicloud
cold cache: 10 parallel installs net 6.8 s ±52% (0.76x) 8.9 s ±16% (1.00x) error (n/a) 6.7 s ±23% (0.75x) 6.7 s ±10% (0.75x) 9.8 s ±9% (1.10x)
warm cache: 10 parallel installs 7.6 s ±35% (0.96x) 7.9 s ±12% (1.00x) error (n/a) 8.5 s ±14% (1.08x) 7.7 s ±14% (0.97x) 7.0 s ±15% (0.89x)
server CPU 2.2 s ±8% (1.00x) no server (n/a) 68 ms ±82% (0.03x) 1.3 s ±19% (0.59x) 15 ms ±31% (0.01x) 1.4 s ±10% (0.62x)
server peak memory 154 MB ±14% (1.00x) no server (n/a) 995 MB ±27% (6.47x) 322 MB ±3% (2.09x) 67 MB ±3% (0.44x) 224 MB ±4% (1.46x)

The request workload drives a swarm against each warm server: one user, then 32, each a client that fetches project pages and reads every byte of the body, the way a resolver does. The pages average ~480 KB, so this row prices full page transfers, not header round-trips.

simple-page requests against a warm cache: peak rate, and p95 latency at 70% of it (ratios vs direct)
median over the run's rounds, ± coefficient of variation; a net row is upstream-bound and not a peryx measurement
peryx direct devpi proxpi pypiserver pypicloud
1 user: requests/s 573 req/s ±3% (2.16x) 265 req/s ±2% (1.00x) 74 req/s ±1% (0.28x) 95 req/s ±0% (0.36x) 244 req/s ±2% (0.92x) 197 req/s ±0% (0.74x)
1 user: p95 latency 9 ms ±1% (0.86x) 11 ms ±4% (1.00x) 94 ms ±8% (8.87x) 43 ms ±0% (4.10x) 11 ms ±4% (1.01x) 30 ms ±8% (2.84x)
32 users: requests/s 2,512 req/s ±2% (2.87x) 876 req/s ±1% (1.00x) 79 req/s ±1% (0.09x) 415 req/s ±0% (0.47x) 884 req/s ±0% (1.01x) 207 req/s ±0% (0.24x)
32 users: p95 latency 16 ms ±1% (0.36x) 44 ms ±1% (1.00x) 483 ms ±2% (10.95x) 84 ms ±1% (1.91x) 106 ms ±2% (2.41x) 169 ms ±1% (3.82x)
server CPU 2m 06.1s ±2% (1.00x) no server (n/a) 32.0 s ±1% (0.25x) 1m 08.1s ±0% (0.54x) 2.9 s ±1% (0.02x) 30.5 s ±0% (0.24x)
server peak memory 336 MB ±4% (1.00x) no server (n/a) 1,152 MB ±0% (3.43x) 352 MB ±1% (1.05x) 68 MB ±3% (0.20x) 196 MB ±7% (0.58x)

Every table ends with two resource rows: the CPU seconds and peak resident memory of the server's whole process tree while its workload ran, compared against peryx (direct runs no server, so it cannot anchor them). Speed alone hides a trade: proxpi's hot-transfer lead comes from holding wheels in memory at three to five times peryx's footprint, and pypiserver's near-zero CPU reflects that it redirects file downloads to PyPI instead of serving them.

Every server is measured the same way, on the same machine, in the same run, and one command reproduces every table: see run the benchmarks.

On this page