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.
| Registry | Stack | On a cold pull | Persisted cache |
|---|---|---|---|
| peryx | one static Rust binary, async (tokio/axum) | streams each blob through, teeing into the store; concurrent misses for one layer share one fetch | content-addressed blobs on disk |
| distribution | the reference registry (registry:2), Go, in proxy mode | fetches and stores each blob, then serves it | filesystem, by repository |
| zot | a Go registry with an on-demand sync extension | syncs the image from upstream when a manifest is first pulled | filesystem, by repository |
| direct (Docker Hub) | no proxy, the client talks to Docker Hub | the full upstream pull, every time | 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 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.
| 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) |
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.
| 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.
| 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.
| 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.
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