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.
| model | Mac16,10 |
|---|---|
| CPU | Apple M4 (arm64) |
| cores | 10 (4 performance + 6 efficiency) |
| memory | 16 GB |
| OS | macOS 26.5 Tahoe (kernel 25.5.0) |
| mount | disk | size | role |
|---|---|---|---|
/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 |
| single | 8 parallel | measures | |
|---|---|---|---|
| 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.
| Registry | Deployment | On a cold pull | Persisted cache |
|---|---|---|---|
| peryx | Single process | Streams each blob into the store; concurrent misses for one layer share one fetch | Content-addressed blobs on disk |
| distribution | Reference registry in proxy mode | Fetches and stores each blob, then serves it | Filesystem, by repository |
| zot | Registry with on-demand sync | Syncs the image when a client first requests its manifest | Filesystem, by repository |
| direct (Docker Hub) | No intermediary | Fetches the full image for each pull | None |
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.
| 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.
| 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.
| 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.
| 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.
| 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.
| 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.
| 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.
Related
- What the roles mean for containers: the OCI ecosystem
- Why the cold path keeps up and the warm path pulls ahead: performance and methodology
- Run the container registry: run a container registry