Documentation

Performance

peryx streams image blobs into a content-addressed store while serving the client. Concurrent pulls of one uncached layer share one upstream fetch. OCI benchmarks compare that behavior with other Docker Hub caches on the same Apple Silicon host. Pulls use crane for manifest traversal and Bearer authentication. Layer transfers use in-process HTTP so subprocess startup does not affect registry throughput.

the host every table on this page was measured on
modelMac16,10
CPUApple M4 (arm64)
cores10 (4 performance + 6 efficiency)
memory16 GB
OSmacOS 26.5 Tahoe (kernel 25.5.0)
storage used by the benchmark run
mountdisksizerole
/System/Volumes/Data SSD, apfs 245.1 GB benchmark scratch: every server's store and cache ; benchmark target
/Volumes/T9Mini SSD, hfs, removable 2.0 TB the checkout the peryx binary is built and run from
what the bare machine can do, measured the same run
median of five samples after a discarded warm-up, ± coefficient of variation; a warm registry lives between the page-cache read and the socket
single8 parallelmeasures
memory copy 44.3 GB/s ±2% 52.9 GB/s ±1% moving bytes between two buffers larger than L2
disk write 1.5 GB/s ±17% 1.8 GB/s ±3% a sequential write to /System/Volumes/Data (SSD), flushed to the device
file read, warm 15.4 GB/s ±7% 31.3 GB/s ±2% reading a file on /System/Volumes/Data (SSD) that the page cache already holds, as a warm registry does
minimal HTTP server 10.2 GB/s ±3% 7.1 GB/s ±2% a 30 MB body over 127.0.0.1 from a server that only writes a buffer

Benchmark setup

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.

Compared registries

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.

RegistryDeploymentOn a cold pullPersisted cache
peryxSingle processStreams each blob into the store; concurrent misses for one layer share one fetchContent-addressed blobs on disk
distributionReference registry in proxy modeFetches and stores each blob, then serves itFilesystem, by repository
zotRegistry with on-demand syncSyncs the image when a client first requests its manifestFilesystem, by repository
direct (Docker Hub)No intermediaryFetches the full image for each pullNone

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, 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)

Concurrent 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, peryx finishes cold in 1.1 s and warm in 0.6 s without Docker Hub latency variance.

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)

Endpoint coverage

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.

For revalidation, peryx sends a HEAD request that returns the tag's digest without a body. It fetches the manifest only when that digest changes. The freshness window removes this request from fresh reads, and the single-flight gate reduces a burst of stale-tag pulls to one upstream check. cache_ttl_secs sets the window and defaults to five minutes.

tag list used to send each request from a single-member proxy to its upstream, so the benchmark measured a Docker Hub round trip. peryx now caches this mutable list for cache_ttl_secs, revalidates it after the window, and answers 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