Documentation

Performance

peryx streams an image's blobs to the client while teeing them into a content-addressed store, and concurrent pulls of one uncached layer share a single upstream fetch. This page measures what that buys against the registries you would otherwise put in front of Docker Hub, from a benchmark harness running every registry on one Apple Silicon machine. Pulls go through crane, so a real client handles the manifest walk and the bearer-token dance; layer transfers are read in process over plain HTTP, because a subprocess per stream prices the client rather than the registry.

How this is measured, two ways

Every workload below is measured twice. The against Docker Hub table points each registry at registry-1.docker.io and pulls for real: the cold row carries the actual upstream fetch (the network, Docker Hub's own latency, and the proxy's store write), so it is marked network-bound and kept out of the regression gate, while the warm row is pure cache serving. The shielded table swaps Docker Hub for a local pull-through cache, seeded once and shared by every registry, which removes upstream variance and makes the run reproducible, isolating each registry's own serving cost. Read together they separate what a first pull costs against the real internet from what a registry does with a layer it already holds.

The shielded run is also the answer to Docker Hub's pull limit. A registry with no cache in front passes every client pull straight through, so ten CI jobs pulling one image are ten pulls against your quota, and a rigorous benchmark that restarts four registries on an empty cache each round burns through the hourly ceiling before it finishes. Put a cache in front, which is what peryx is, and one upstream fetch serves everyone behind it, so the fleet's ten cold pulls collapse to the single fetch the fleet numbers show.

The field

Every party is a pull-through cache of Docker Hub, so the tables read against direct: a pull straight from registry-1.docker.io with nothing in between, the baseline every ratio compares against.

RegistryStackOn a cold pullPersisted cache
peryxone static Rust binary, async (tokio/axum)streams each blob through, teeing into the store; concurrent misses for one layer share one fetchcontent-addressed blobs on disk
distributionthe reference registry (registry:2), Go, in proxy modefetches and stores each blob, then serves itfilesystem, by repository
zota Go registry with an on-demand sync extensionsyncs the image from upstream when a manifest is first pulledfilesystem, by repository
direct (Docker Hub)no proxy, the client talks to Docker Hubthe full upstream pull, every timenone

Pulling images

The pull workload fetches six official images through each registry, cold (empty cache, every layer a miss) then warm (the cache full, the client reset). Against Docker Hub the warm row is where a cache earns its place: peryx serves a warm pull in 0.7 s against 7.4 s to pull Docker Hub yourself, and ahead of distribution (2.7 s) and zot (6.6 s). The cold row carries the real upstream fetch and is network-bound, yet peryx fills its cache in 6.7 s, ahead of direct's 7.4 s despite also verifying and storing every layer: content-addressing fetches each base layer the six images share exactly once. distribution pays 13.5 s and zot's on-demand sync far more, 51.2 s. You take the cold cost once per image, and every pull after is the warm row.

pull 6 images through each registry (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 distribution zot
cold cache net 6.7 s ±2% (0.91x) 7.4 s ±0% (1.00x) 13.5 s ±1% (1.82x) 51.2 s ±3% (6.91x)
warm cache 741 ms ±22% (0.10x) 7.4 s ±3% (1.00x) 2.7 s ±3% (0.37x) 6.6 s ±4% (0.90x)

Behind the mirror the same warm serving stands on its own, free of the network: peryx answers in 0.6 s, clear of distribution (2.7 s) and zot (6.7 s), and the cold fill settles to 3.2 s against direct's 2.8 s, the reproducible view of the numbers above.

pull 6 images through each registry (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 distribution zot
cold cache net 3.2 s ±2% (1.13x) 2.8 s ±4% (1.00x) 5.7 s ±1% (2.02x) 17.3 s ±107% (6.10x)
warm cache 560 ms ±2% (0.20x) 2.8 s ±3% (1.00x) 2.7 s ±1% (1.00x) 6.7 s ±1% (2.43x)

Layer throughput

Once a layer is cached, how fast does it leave the registry? The throughput workload warms every registry with one large layer (30 MB of python:3.12-slim), then streams it back, alone and under eight parallel readers. Warming first keeps the row fair across designs: a pull-through proxy caches the layer on a blob request while a sync-based registry mirrors it from the manifest, so pulling the image once gives every registry the layer to serve however its store holds it. All three registries then stream from the page cache, and the interesting number is how many readers each one needs to saturate the machine.

peryx serves a single stream at 6,838 MB/s against zot's 2,772, and eight readers take it to 8,192. One reader is already most of the way there, because peryx pipelines its reads: the next chunk is in flight while the current one goes to the socket, so a lone client never waits on the disk. zot takes the kernel's zero-copy sendfile path, cheaper per byte but serialized behind one reader's syscalls, so it needs eight overlapping readers to climb from 2,772 to 7,002 MB/s and reach the same neighbourhood.

That neighbourhood is the socket, not the registry. On this box a server that does nothing but write a buffer hands a 30 MB body to one loopback client at 10.2 GB/s. peryx's single stream is the same order of magnitude as that; zot's 2.8 GB/s is not. Both registries leave the network-bound rows far behind, where Docker Hub itself manages 61 MB/s and distribution 99.

streaming one large cached layer (library/python:3.12-slim), alone and eight-way (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 distribution zot
hot cache: single stream 6,838 MB/s ±5% (112.21x) 61 MB/s ±1% (1.00x) 99 MB/s ±3% (1.62x) 2,772 MB/s ±3% (45.49x)
hot cache: 8 parallel streams 8,192 MB/s ±9% (77.50x) 106 MB/s ±3% (1.00x) 253 MB/s ±9% (2.39x) 7,002 MB/s ±16% (66.24x)

Behind the mirror the shape repeats and the gap widens slightly: peryx streams 7,164 MB/s to one reader against zot's 2,848, and at eight the two finish level, 8,747 to 8,694. The single-stream ratio, near 2.5x in both readings, is the number to carry away; the eight-way figures are pressed against the machine and say more about the loopback than about either registry.

streaming one large cached layer (library/python:3.12-slim), alone and eight-way (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 distribution zot
hot cache: single stream 7,164 MB/s ±8% (28.48x) 252 MB/s ±9% (1.00x) 86 MB/s ±5% (0.34x) 2,848 MB/s ±4% (11.32x)
hot cache: 8 parallel streams 8,747 MB/s ±20% (32.29x) 271 MB/s ±18% (1.00x) 261 MB/s ±6% (0.97x) 8,694 MB/s ±11% (32.10x)

A pull fleet

The fleet workload is ten clients pulling one image (node:22-alpine) at once, each with its own empty cache, exactly like ten CI jobs landing on a runner pool together. Against Docker Hub it is where single-flight pays off most: peryx's ten clients share the upstream fetches and finish cold in 2.2 s and warm in 0.7 s, against 6 to 12 s cold for the others. It is the rate-limit story in one row: those ten pulls cost the upstream a single fetch through peryx, where direct sends all ten to Docker Hub and stays at 6.4 s warm because it caches nothing.

ten clients pull library/node:22-alpine 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 distribution zot
cold cache: 10 parallel pulls net 2.2 s ±1% (0.36x) 6.2 s ±1% (1.00x) 12.0 s ±8% (1.93x) 8.2 s ±1% (1.32x)
warm cache: 10 parallel pulls 703 ms ±12% (0.11x) 6.4 s ±1% (1.00x) 6.4 s ±30% (0.99x) 1.7 s ±10% (0.26x)

Behind the mirror the shape survives without the network: peryx finishes cold in 1.1 s and warm in 0.6 s, still ahead of the field, and the numbers stop moving with Docker Hub's weather.

ten clients pull library/node:22-alpine 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 distribution zot
cold cache: 10 parallel pulls net 1.1 s ±6% (0.36x) 3.1 s ±1% (1.00x) 9.2 s ±2% (2.98x) 3.8 s ±82% (1.23x)
warm cache: 10 parallel pulls 561 ms ±18% (0.18x) 3.1 s ±2% (1.00x) 3.1 s ±3% (0.99x) 1.6 s ±3% (0.50x)

Every endpoint, not just the three a pull touches

crane pull needs three endpoints: the version check, a manifest, and a blob. The workloads above therefore never measure the rest of what a registry serves, and an unmeasured endpoint is where a regression hides. Unlike a PyPI index, an OCI registry's paths are fixed by the distribution spec, so these rows compare like for like across the field.

one warm request to each served endpoint; an empty cell is an endpoint the registry does not offer (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 distribution zot
version check 55 µs ±27% (0.12x) 461 µs ±7% (1.00x) 624 µs ±15% (1.35x) 71 µs ±17% (0.15x)
manifest by tag 58 µs ±21% (0.00x) 231 ms ±2% (1.00x) 245 ms ±4% (1.06x) 967 ms ±1% (4.18x)
manifest by tag (HEAD) 53 µs ±34% (0.00x) 236 ms ±3% (1.00x) 243 ms ±3% (1.03x) 948 ms ±1% (4.02x)
manifest by digest 59 µs ±35% (0.05x) 1.2 ms ±19% (1.00x) 1.6 ms ±23% (1.32x) 149 µs ±10% (0.12x)
blob (HEAD) 83 µs ±27% (0.10x) 842 µs ±10% (1.00x) 877 µs ±10% (1.04x) 287 µs ±11% (0.34x)
config blob 97 µs ±27% (0.10x) 988 µs ±8% (1.00x) 1.2 ms ±2% (1.25x) 102 µs ±11% (0.10x)
layer range (1 MiB) 368 µs ±20% (0.06x) 6.3 ms ±21% (1.00x) 5.4 ms ±10% (0.85x) 461 µs ±27% (0.07x)
tag list net 341 ms ±1% (1.01x) 337 ms ±2% (1.00x) 356 ms ±2% (1.06x) 97 µs ±13% (0.00x)
tag list (paginated) net 335 ms ±1% (0.98x) 341 ms ±2% (1.00x) 358 ms ±2% (1.05x) 94 µs ±16% (0.00x)

peryx answers a cached manifest, by tag or by digest, in tens of microseconds, and a HEAD costs what the GET costs without the body. That is worth reading carefully rather than as a win. A tag is mutable, so distribution and Docker Hub ask upstream whether it still points where it did, on every request; peryx serves a tag from cache while it is fresh and revalidates once its freshness window elapses, and zot re-checks its sync. The microseconds buy you a tag that can be up to one freshness window stale, which is the trade a caching proxy exists to make. A manifest by digest is immutable, and there the comparison is clean.

The revalidation itself is cheap: peryx asks what the tag points at with a HEAD, which answers with a digest and no body, and only fetches the manifest when that digest has moved. So the freshness window is not buying a round trip, it is buying the absence of one — and a burst of pulls of the same stale tag collapses into a single upstream check through the single-flight gate. The window is cache_ttl_secs, five minutes by default.

tag list used to be the one row peryx lost, and it lost it for a structural reason: a single-member proxy passed the request straight to its upstream on every request, so the row measured a round trip to Docker Hub rather than a registry serving something it holds. A tag list is mutable, which is why it asked — but that is what a freshness window is for. It is now cached like a tag: trusted for cache_ttl_secs, revalidated after, and answered from the last list when the upstream cannot be reached, bounded by max_stale_secs. A burst of listings costs the upstream one request, not one per client.

Reproducing

Both readings come from the same harness, one straight against Docker Hub and one behind a local pull-through cache that makes the run rate-limit-free and repeatable. See run the benchmarks for the commands and the Docker Hub credentials they need.

On this page