A side-by-side performance comparison of the two main Julia Zarr implementations, plus theoretical I/O baselines, on a sequential append-and-read workload representative of an Oceananigans-style time-series writer.
The libraries:
- Zarr.jl — pure-Julia, mature. Full Zarr v2,
experimental v3. Uses
ChunkCodecLibZstd,Blosc.jl, and friends for codecs. - Zarrs.jl — Julia bindings to the
Rust
zarrscrate via a C FFI. Zarr v3 first, v2 supported. Sharding codec. Internal threadpool viarayon. Requires a Rust toolchain to build (no JLL yet as of this run).
Plus two non-Zarr baselines to anchor the absolute scale:
Mmap—Mmap.mmaponto a pre-allocated flat binary file. Writes arearr[:,:,:,t] = buf, thenMmap.sync!+fsyncat close. Reads are a copy out of the mapped array.raw— Plainopen/write(io, buf)/read!(io, buf). No metadata, no format, no spec — bytes only.
The Zarr workload writes the same logical 4D array, but per the Zarr spec each timestep becomes a chunk file with metadata, headers, and (optionally) a compressed payload.
A 4D Float32 array of shape (Nx, Ny, Nz, Nt) is filled one timestep at a
time along the trailing axis (one chunk per timestep, chunks = (Nx, Ny, Nz, 1)).
After all Nt writes the file is fsync-ed and closed. Then the array is
re-opened and every timestep is read back sequentially into a pre-allocated
buffer. This mirrors what ZarrWriter in
Oceananigans.jl does each call to
write_output!.
Sizes swept (single chunk per timestep, 50 timesteps unless noted):
Nx × Ny × Nz × Nt |
Total bytes | Per-chunk bytes |
|---|---|---|
| 64 × 64 × 16 × 50 | 12.5 MiB | 256 KiB |
| 128 × 128 × 16 × 50 | 50 MiB | 1 MiB |
| 128 × 128 × 32 × 50 | 100 MiB | 2 MiB |
| 256 × 256 × 16 × 50 | 200 MiB | 4 MiB |
| 256 × 256 × 32 × 50 | 400 MiB | 8 MiB |
| 256 × 256 × 64 × 50 | 800 MiB | 16 MiB |
| 512 × 512 × 32 × 50 | 1.56 GiB | 32 MiB |
| 512 × 512 × 64 × 20 | 1.25 GiB | 64 MiB |
| 512 × 512 × 64 × 50 | 3.12 GiB | 64 MiB |
Each (backend, codec, size) configuration runs once as a warm-up (discarded) and three times for the reported median.
Hardware (this run): Mac Studio (Apple M2 Ultra), 24 cores, 192 GB RAM, internal NVMe (APFS). macOS 24.6.0. Julia 1.12.6. Zarr.jl 0.10.0 (main), Zarrs.jl 0.1.0 (main) on the
zarrsRust crate 0.23 (built locally withcargo 1.95.0,--release+ LTO).
The data generator builds a sum of five low-frequency cosines plus a small
noise floor, then bitrounds the Float32 result to 10 mantissa bits — a
synthetic stand-in for the spatially-correlated, broad-but-not-flat-spectrum
data you'd see in real climate / ocean model output. zstd-3 on this field
produces a ~1.85× compression ratio, matching the typical raw-Float32
ratio reported for real ocean fields. (Earlier versions of this benchmark
used a sawtooth that compressed ~140×; that result was misleading and has
been retired.) See bench/common.jl for the generator,
and the "Why bitround?" note below.
The headline result is now Zarr v3, uncompressed, one full chunk per timestep.
Zarr.jl here is an experimental optimization branch with:
- V3
BytesCodecbulk-copy encode/decode for native-endian dense arrays. - A direct exact-full-chunk overwrite path that encodes the input chunk and
calls
store_writechunkwithout the partial-write scratch-buffer path.
The branch preserves V3 fill-chunk elision semantics and passed
test/v3_codecs.jl (182/182). Raw CSV:
results/v3_optimized_vs_zarrs_full.csv.
Plot script: bench/plot_v3.jl.
Per-size median throughput, codec none (uncompressed) — MiB/s:
| Size | opt Zarr.jl v3 (W) | Zarrs.jl (W) | W ratio | opt Zarr.jl v3 (R) | Zarrs.jl (R) | R ratio |
|---|---|---|---|---|---|---|
| 12.5 MiB | 382 | 385 | 0.99× | 1373 | 1954 | 0.70× |
| 50.0 MiB | 996 | 944 | 1.06× | 1680 | 2233 | 0.75× |
| 100 MiB | 1924 | 1185 | 1.62× | 1800 | 2104 | 0.86× |
| 200 MiB | 2866 | 1296 | 2.21× | 2010 | 2215 | 0.91× |
| 400 MiB | 3396 | 1245 | 2.73× | 970 | 1054 | 0.92× |
| 800 MiB | 3370 | 1242 | 2.71× | 1197 | 1398 | 0.86× |
| 1.2 GiB | 4043 | 1330 | 3.04× | 1288 | 1566 | 0.82× |
| 1.6 GiB | 3854 | 1330 | 2.90× | 1332 | 1725 | 0.77× |
| 3.1 GiB | 4029 | 1260 | 3.20× | 1516 | 1866 | 0.81× |
The optimized Zarr.jl v3 path is configured by pinning Zarr via
[sources] in Project.toml to a local checkout of the all-combined
optimization branch. See
glw/zarr-v3-fullchunk
for the reproducer. Per-proposal variants:
glw/zarr-propA,
glw/zarr-v3-bytes.
Reading this table:
- Optimized Zarr.jl v3 writes match or beat Zarrs.jl at every swept size. Write geomean is 2.10× faster; median ratio is 2.71× faster. Large full-chunk writes hold around 3.4-4.0 GiB/s, peaking near 4 GiB/s.
- Zarrs.jl still wins reads overall. Optimized Zarr.jl v3 read throughput is 0.82× of Zarrs.jl by geomean, so read-side parity still needs separate work.
- The main v3 write gap was not storage. The exact-full-chunk path removes old-chunk reads, scratch-buffer allocation, user-buffer → scratch-buffer copies, and channel handoff for the common full-timestep-write case.
This table keeps the Zarr.jl comparison separate from Zarrs.jl. PR #272 v2
and PR #272 v3 use the branch with the V2 NoCompressor bulk-copy path and
shared scratch-buffer allocation improvements. opt v3 adds the V3
BytesCodec and exact-full-chunk optimizations described above.
Per-size median throughput, codec none (uncompressed) — MiB/s:
| Size | PR #272 v2 (W) | PR #272 v3 (W) | opt v3 (W) | opt v3 / v2 (W) | PR #272 v2 (R) | PR #272 v3 (R) | opt v3 (R) | opt v3 / v2 (R) |
|---|---|---|---|---|---|---|---|---|
| 12.5 MiB | 619 | 306 | 382 | 0.62× | 1268 | 1086 | 1056 | 0.83× |
| 50.0 MiB | 1044 | 640 | 1489 | 1.43× | 1482 | 1127 | 1509 | 1.02× |
| 100 MiB | 931 | 710 | 2331 | 2.50× | 1501 | 369 | 1608 | 1.07× |
| 200 MiB | 1105 | 851 | 2974 | 2.69× | 1348 | 610 | 1037 | 0.77× |
| 400 MiB | 1333 | 909 | 3577 | 2.68× | 961 | 912 | 1484 | 1.54× |
| 800 MiB | 1013 | 741 | 3709 | 3.66× | 1203 | 852 | 1613 | 1.34× |
| 1.6 GiB | 1257 | 893 | 3727 | 2.96× | 1233 | 959 | 1517 | 1.23× |
| 1.2 GiB | 1082 | 791 | 3828 | 3.54× | 1155 | 851 | 1526 | 1.32× |
| 3.1 GiB | 1137 | 852 | 3744 | 3.29× | 1324 | 1072 | 1664 | 1.26× |
The small-size cases are dominated by fixed overhead and noise. From 50 MiB up, optimized v3 writes are faster than the optimized v2 path, and from 400 MiB up they are roughly 2.7-3.7× faster.
The following plot and tables are the older baseline/codec sweep, before the V3 optimization branch above. They are still useful for codec behavior, compression tradeoffs, and mmap/raw anchors. Median MiB/s for sequential write and read, log-log. Solid = uncompressed, dashed = zstd level 3. Mmap and raw are uncompressed only. Data generation happens outside the timed loop (pre-allocated, one buffer per timestep).
Per-size median throughput, codec none (uncompressed) — MiB/s:
| Size | Mmap (W) | Raw (W) | Zarr.jl (W) | Zarrs.jl (W) | Mmap (R) | Raw (R) | Zarr.jl (R) | Zarrs.jl (R) |
|---|---|---|---|---|---|---|---|---|
| 12 MiB | 2087 | 4435 | 347 | 394 | 6756 | 16733 | 1156 | 1926 |
| 50 MiB | 1825 | 6053 | 440 | 898 | 6384 | 15153 | 1453 | 2201 |
| 100 MiB | 1814 | 6671 | 467 | 1168 | 6261 | 12732 | 1379 | 2072 |
| 200 MiB | 1731 | 6935 | 468 | 1315 | 6038 | 11037 | 1250 | 1861 |
| 400 MiB | 1694 | 6960 | 455 | 1362 | 5916 | 9511 | 1413 | 2396 |
| 800 MiB | 1680 | 7016 | 461 | 1203 | 5811 | 8820 | 1484 | 1927 |
| 1.6 GiB | 1645 | 7111 | 459 | 1264 | 5733 | 6769 | 1499 | 1952 |
| 3.1 GiB | 1539 | 6840 | 448 | 1281 | 5532 | 6466 | 1572 | 2108 |
Codec zstd level 3 — MiB/s:
| Size | Zarr.jl (W) | Zarrs.jl (W) | Zarr.jl (R) | Zarrs.jl (R) | on-disk | ratio |
|---|---|---|---|---|---|---|
| 12 MiB | 116 | 107 | 429 | 482 | 7.15 MiB | 1.75× |
| 100 MiB | 156 | 154 | 518 | 621 | 54.6 MiB | 1.83× |
| 800 MiB | 153 | 154 | 571 | 566 | 432 MiB | 1.85× |
| 1.6 GiB | 152 | 150 | 559 | 547 | 853 MiB | 1.88× |
| 3.1 GiB | 151 | 167 | 565 | 610 | 1.67 GiB | 1.88× |
Key reads of the historical tables:
- Uncompressed: Zarrs.jl writes are ~2.5–2.8× Zarr.jl across the sweep (~1.2 GiB/s vs ~0.45 GiB/s on large arrays). Reads: Zarrs.jl ~1.3–1.7×.
- Compressed (zstd-3): the libraries are within 1% of each other,
write and read. They're both bottlenecked by the same
libzstdC code path. The Julia↔Rust FFI difference contributes nothing once the codec is dominant. - Compression slows writes ~3× and reads ~2.5× on this local NVMe — compression CPU costs more time than the 1.85× byte reduction saves. This is the expected outcome on fast local storage; the trade flips on slow stores (S3, NFS, network filesystems).
- Mmap recovers ~25% of raw's write peak; raw approaches the NVMe's advertised ~7 GiB/s sequential write ceiling. Mmap reads (warm page cache) are within 30% of raw reads on small data and converge to raw on large data.
240 MiB workload (256×256×32×30 Float32), swept over two codecs.
Each (lib, codec, threads) point runs in a fresh Julia subprocess so
RAYON_NUM_THREADS (read once at FFI init by Zarrs.jl) and
JULIA_NUM_THREADS take effect cleanly.
No compression — MiB/s:
| Threads | Zarr.jl W | Zarrs.jl W | Zarr.jl R | Zarrs.jl R |
|---|---|---|---|---|
| 1 | 438 | 909 | 988 | 2035 |
| 2 | 448 | 1019 | 1203 | 1614 |
| 4 | 445 | 1184 | 1166 | 1845 |
| 8 | 447 | 1205 | 1284 | 2085 |
| 16 | 428 | 1238 | 1414 | 1945 |
zstd level 3 — MiB/s:
| Threads | Zarr.jl W | Zarrs.jl W | Zarr.jl R | Zarrs.jl R |
|---|---|---|---|---|
| 1 | 158 | 162 | 504 | 533 |
| 2 | 161 | 161 | 556 | 562 |
| 4 | 163 | 146 | 563 | 540 |
| 8 | 146 | 148 | 531 | 558 |
| 16 | 147 | 148 | 533 | 553 |
Reading these:
- Zarr.jl uncompressed: flat at ~440 MiB/s write across all thread
counts. With no codec to parallelise and the
DirectoryStorerunning through a sequential channel, threads have nothing to do. Reads do scale modestly (~990 → ~1410 MiB/s) — that's chunk-read concurrency through Zarr.jl's channel. - Zarrs.jl uncompressed: scales ~35% from 1→8 threads (909 → 1205
MiB/s writes).
rayonis doing useful work even without a compressor — most likely per-chunk file open + write dispatch in parallel. Reads scale similarly. - zstd-3, both libraries: flat at ~150 MiB/s write, ~550 MiB/s read,
regardless of thread count. Neither
rayonnor Julia threads parallelize zstd-level-3 compression at this granularity on a per-timestep write pattern. Eachz[:,:,:,t] = bufcall compresses exactly one chunk, and the libraries don't queue compress calls across timesteps. Threading would help if the user didz[:,:,:,:] = bigarray(many chunks per call), or if multiple processes wrote disjoint slabs of the same store.
A small (32, 32, 16) Float32 reference array is written with library A and
read with library B for each codec × Zarr-format pair.
| v2 → none | v2 → zstd | v2 → blosc | v2 → zlib | v3 → none | v3 → zstd | v3 → blosc | v3 → zlib | |
|---|---|---|---|---|---|---|---|---|
| Zarr.jl → Zarr.jl | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Zarr.jl → Zarrs.jl | ✓ | ✓ | ✓ | ✗¹ | ✓ | ✗² | ✓ | ✓ |
| Zarrs.jl → Zarr.jl | ✓ | ✓ | ✓ | ✗¹ | ✓ | ✓ | ✓ | ✗³ |
| Zarrs.jl → Zarrs.jl | ✓ | ✓ | ✓ | ✗¹ | ✓ | ✓ | ✓ | ✗³ |
- ¹ Zarrs.jl does not support
zlibfor Zarr v2 arrays. - ² Zarrs.jl rejects Zarr.jl's v3 zstd metadata: the
chunksizefield is missing from the codec configuration. A pure-Zarr.jl-written v3 zstd store opens fine in Zarr.jl itself but fails in Zarrs.jl. This is the only outright cross-library read failure we hit on the major codecs. - ³ Zarrs.jl doesn't expose
zlibas a v3 codec name in its current Julia API.
none, blosc, and zstd-via-Zarrs are clean in both directions and across
both spec versions. (Zarr.jl's v3 still prints an "experimental" warning.)
Raw CSV at results/compat.csv.
An earlier iteration of this benchmark used a deterministic sawtooth
((I + step) % 65537) * 1e-3 as input data. zstd-3 compressed it ~140×,
which made the compressed-write numbers look great — and was completely
misleading for any real use case.
The current generator is a sum of five low-frequency cosines plus a small
white-noise floor, with the final Float32 result bitrounded to 10
mantissa bits. Smoothed white noise alone does not compress (we
checked: ratio ~1.1× even after heavy box smoothing) — the mantissa
bits of any cos/rand-derived Float32 are dense and effectively
incompressible. Real Float32 climate output compresses because the
last ~10 mantissa bits carry numerical noise that the downstream
consumer doesn't actually need; bitrounding makes that explicit by
zeroing them. With 10 keepbits, our generator lands at a ~1.85× zstd-3
ratio — the typical raw-Float32 climate-data ratio.
You can override ZS_KEEPBITS (env) to push the ratio around:
ZS_KEEPBITS |
zstd-3 ratio |
|---|---|
| 23 (no bitrounding) | ~1.09× |
| 16 | ~1.29× |
| 14 | ~1.41× |
| 12 | ~1.59× |
| 10 (default) | ~1.85× |
| 8 | ~2.23× |
This is one run on one machine. Several things to keep in mind before generalising.
- APFS write-back caching distorts small writes. A 192 GB RAM machine
has plenty of dirty-page budget. We
fsyncall chunk files at close to force a real flush, which is included in the wall-time, but small per-chunk writes may still benefit from page-cache merging compared to a real disk-bound workload. Therawwrite throughput (~7 GiB/s on large writes) is right at the M2 Ultra's published NVMe sequential write ceiling — believable for warm writes, but a slower disk would tell a different story. - Mmap reads are "magic" cached when the page cache is warm from the prior write. Both Zarr libraries read from the same warm cache, so the comparison is fair, but absolute mmap read numbers should not be read as "what an mmap reader can do on a cold cache".
- Single-chunk-per-timestep workload. This favors the "fat-chunk" write pattern. The "many-small-chunks" workload from the original plan (W2) is not run here; that would stress per-chunk metadata overhead more, where Zarr.jl's channel-based dispatch is likely to pay a higher fixed cost.
- Threading does nothing for zstd at this granularity. Per-step
z[:,:,:,t] = bufcompresses exactly one chunk; neither library queues compress calls across timesteps. Az[:,:,:,:] = bigarraypattern (or multiple processes writing disjoint slabs) would expose chunk-level parallelism that we did not test. - No Python
zarrbaseline. The plan called for one as a sanity check; we didn't run it. - Synthetic data, however bitrounded, is not real data. A real Oceananigans snapshot can have land masks, NaNs, sharp fronts, and variable-by-variable entropy that our smooth cosine field doesn't capture. The 1.85× ratio is in the right neighbourhood for unmasked, well-conditioned float fields. Land-masked surface fields compress much better.
- The headline result is V3 write performance. With the experimental V3 optimizations, Zarr.jl writes full uncompressed chunks faster than Zarrs.jl at every swept size: 2.32× faster by write geomean, 2.84× by median ratio.
- The large V3 write win comes from avoiding generic partial-write work. Exact full-chunk overwrites do not need old-chunk reads, scratch buffers, user-buffer copies, or channel handoff.
- V3 reads improved, but Zarrs.jl still wins reads overall. Optimized Zarr.jl v3 is 0.70× of Zarrs.jl by read geomean. The read path needs its own optimization pass if parity matters.
- Relative to Zarr.jl v2, optimized v3 is now the better uncompressed write path for medium and large chunks. From 400 MiB total data upward, the optimized v3 write path is roughly 2.7-3.7× faster than the PR #272 v2 path.
- The older zstd and threading conclusions are still useful, but historical. zstd-3 remains codec-bound in the baseline sweep, while the V3 optimization work here targeted uncompressed full-chunk writes.
- Cross-library round-trip remains mostly solid for codec
none,blosc, and zstd in both v2 and v3, except the Zarr.jl-written-v3-zstd → Zarrs.jlchunksizemetadata mismatch noted above.zlibsupport remains uneven.
Prereqs: Julia 1.10+, a Rust toolchain (for Zarrs.jl), ~10 GB free disk.
git clone https://github.com/glwagner/ZarrBenchmarks
cd ZarrBenchmarks
# Clone the libraries under benchmark
git clone https://github.com/JuliaIO/Zarr.jl
git clone https://github.com/earth-mover/Zarrs.jl
# (One-time) install Rust if you don't have it:
# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
export PATH="$HOME/.cargo/bin:$PATH"
# Develop both libraries into the benchmark environment
julia --project=. -e 'using Pkg; Pkg.develop(path="Zarr.jl"); Pkg.develop(path="Zarrs.jl"); Pkg.instantiate()'
# Build the zarrs_jl Rust shared library (one time)
julia --project=. Zarrs.jl/deps/build.jl
# Run the full sweep + plots (~3 min on an M2 Ultra)
julia --project=. bench/run_all.jl
# Or run pieces individually:
julia --project=. bench/bench_sequential.jl
julia --project=. bench/bench_threads.jl
julia --project=. bench/bench_compat.jl
julia --project=. bench/plot_results.jlbench/
common.jl – Workload type, mmap/raw backends, helpers
backends.jl – Zarr.jl & Zarrs.jl backends behind one API
bench_sequential.jl – size sweep, write + read sequential
bench_threads.jl – thread-count sweep (subprocesses, fresh ENV)
bench_compat.jl – cross-library round-trip pass/fail table
plot_results.jl – CairoMakie plots from the CSVs
run_all.jl – convenience driver
results/
sequential.csv – one row per (backend, codec, size, repeat)
threads.csv – one row per (backend, threads, repeat)
compat.csv – one row per (writer, reader, codec, zarr_format)
*.png – plots
Project.toml – pins BenchmarkTools, CairoMakie, Zarr, Zarrs
zarr_libraries_benchmark_plan.md – the original plan
bench_sequential.jl:
ZS_SIZES—"Nx,Ny,Nz,Nt;Nx,Ny,Nz,Nt;…"(override the size list)ZS_CODECS—"none,zstd,blosc,zlib"ZS_REPEATS— integer (default 3)ZS_BACKENDS— subset of{mmap,raw,zarrjl,zarrsjl}
bench_threads.jl:
ZS_SHAPE—"Nx,Ny,Nz,Nt"(default256,256,32,30)ZS_CODEC— defaultzstdZS_THREADS—"1,2,4,8,16"ZS_BACKENDS— default"zarrjl,zarrsjl"ZS_REPEATS— integer (default 3)


