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 driving crane against each registry on one Apple Silicon laptop.
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.5 s against 7.2 s to pull Docker Hub yourself, and ahead of distribution (2.4 s) and zot (6.4 s). The cold row carries the real upstream fetch and is network-bound, yet peryx fills its cache in 6.7 s, level with direct's 7.3 s despite also verifying and storing every layer: content-addressing fetches each base layer the six images share exactly once. distribution pays 12.9 s and zot's on-demand sync far more, 51.3 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 ±3% (0.92x) | 7.3 s ±1% (1.00x) | 12.9 s ±0% (1.77x) | 51.3 s ±4% (7.07x) |
| warm cache | 527 ms ±1% (0.07x) | 7.2 s ±1% (1.00x) | 2.4 s ±3% (0.33x) | 6.4 s ±6% (0.89x) |
Behind the mirror the same warm serving stands on its own, free of the network: peryx answers in 2.2 s, level with distribution (2.6 s) and clear of zot (6.4 s), and the cold fill settles to 3.0 s against direct's 2.7 s, the reproducible view of the numbers above.
| peryx | direct | distribution | zot | |
|---|---|---|---|---|
| cold cache net | 3.0 s ±3% (1.14x) | 2.7 s ±0% (1.00x) | 5.7 s ±1% (2.14x) | 18.5 s ±103% (6.95x) |
| warm cache | 2.2 s ±16% (0.83x) | 2.7 s ±0% (1.00x) | 2.6 s ±1% (0.98x) | 6.4 s ±0% (2.40x) |
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 stream from disk. Eight-way peryx and zot run level at the front, 803 and 795 MB/s, both far
over distribution's 148 and direct's 88. zot reaches it through the kernel's sendfile path, copying bytes straight
from the page cache to the socket; peryx pipelines its own reads so the disk read runs ahead of the socket write,
matching zot's throughput despite the userspace copy it still pays. Single-stream the order flips: zot's zero-copy leads
at 181 against peryx's 68, where peryx's per-request setup shows through with only one reader to amortize it. These are
the noisiest rows in the suite: each transfer is a short crane subprocess, so single-stream numbers are dominated by
process overhead and the spreads run wide; read them as broad strokes, not to the digit.
| peryx | direct | distribution | zot | |
|---|---|---|---|---|
| hot cache: single stream | 68 MB/s ±1% (1.80x) | 38 MB/s ±1% (1.00x) | 29 MB/s ±22% (0.78x) | 181 MB/s ±22% (4.79x) |
| hot cache: 8 parallel streams | 803 MB/s ±15% (9.09x) | 88 MB/s ±1% (1.00x) | 148 MB/s ±8% (1.68x) | 795 MB/s ±16% (9.01x) |
Behind the mirror the ordering holds, zot's zero-copy path ahead and peryx next at 576 MB/s eight-way, a stride behind zot's 593; the wide single-stream spreads hold with it, the honest read on a subprocess-bound micro-workload.
| peryx | direct | distribution | zot | |
|---|---|---|---|---|
| hot cache: single stream | 79 MB/s ±52% (0.91x) | 86 MB/s ±30% (1.00x) | 29 MB/s ±16% (0.34x) | 181 MB/s ±7% (2.11x) |
| hot cache: 8 parallel streams | 576 MB/s ±6% (4.12x) | 140 MB/s ±56% (1.00x) | 144 MB/s ±11% (1.03x) | 593 MB/s ±13% (4.25x) |
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.6 s, against 6–9 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.0 s warm because it caches nothing.
| peryx | direct | distribution | zot | |
|---|---|---|---|---|
| cold cache: 10 parallel pulls net | 2.2 s ±7% (0.37x) | 6.1 s ±1% (1.00x) | 8.4 s ±6% (1.39x) | 9.2 s ±6% (1.52x) |
| warm cache: 10 parallel pulls | 613 ms ±28% (0.10x) | 6.0 s ±0% (1.00x) | 3.5 s ±12% (0.57x) | 1.8 s ±11% (0.30x) |
Behind the mirror the shape survives without the network: peryx finishes cold in 1.5 s and warm in 0.9 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.5 s ±27% (0.42x) | 3.7 s ±1% (1.00x) | 7.3 s ±3% (1.98x) | 3.8 s ±81% (1.03x) |
| warm cache: 10 parallel pulls | 923 ms ±30% (0.27x) | 3.5 s ±6% (1.00x) | 2.5 s ±3% (0.71x) | 1.5 s ±2% (0.43x) |
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