Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Pure decode speed with one thread, bytes pre-loaded to memory. **Bold** = best p
| `turbojpeg` | 640 | 818 | 708 | 426 | 613 |
| `jpeg4py` | 636 | 760 | 699 | 423 | 611 |
| `kornia-rs` | 642 | 761 | 664 | 391 | 629 |
| `ajpegli` | 400 | 501 | 323 | 273 | 337 |
| `opencv` | 664 | 841 | 721 | 445 | 645 |
| `imagecodecs` | 677 | 775 | 723 | **457** | 661 |
| `pyvips` | 420 | 586 | 462 | 261 | 413 |
Expand All @@ -68,6 +69,7 @@ Best `images_per_second` across `num_workers ∈ {0, 2, 4, 8}` for each library
| `turbojpeg` | 1,535 @ 4w | 2,800 @ 8w | 1,710 @ 8w | 1,347 @ 4w | 2,389 @ 8w |
| `jpeg4py` | 1,443 @ 4w | 2,453 @ 8w | 1,651 @ 8w | 1,411 @ 8w | 2,312 @ 8w |
| `kornia-rs` | 1,327 @ 8w | 2,394 @ 8w | 1,422 @ 8w | 1,260 @ 8w | 1,951 @ 8w |
| `ajpegli` | 1,458 @ 8w | 2,882 @ 8w | 1,498 @ 8w | 1,448 @ 8w | 2,397 @ 8w |
| `opencv` | 1,457 @ 4w | 2,814 @ 8w | 1,707 @ 8w | 1,419 @ 8w | 2,414 @ 8w |
| `imagecodecs` | 1,543 @ 4w | 2,476 @ 8w | 1,677 @ 8w | 1,443 @ 8w | 2,242 @ 8w |
| `pillow` | 1,283 @ 4w | 2,465 @ 8w | 1,565 @ 8w | 1,387 @ 8w | 2,350 @ 8w |
Expand All @@ -79,7 +81,7 @@ Best `images_per_second` across `num_workers ∈ {0, 2, 4, 8}` for each library

<!-- BENCH:metadata:start -->

_**5 platforms** · 50,000 images · 5 runs each · latest run 2026-04-22_
_**5 platforms** · 50,000 images · 5 runs each · latest run 2026-05-20_

<!-- BENCH:metadata:end -->

Expand All @@ -93,6 +95,7 @@ Current headline patterns:
- `torchvision` wins both AMD platforms at peak `DataLoader` throughput and is effectively tied for first on Neoverse V2.
- `imageio` is not a single-thread leader, but wins peak `DataLoader` throughput on Neoverse V2 in the current GCP runs.
- OpenCV is rarely the absolute winner, but is consistently close to the local winner and has successful `DataLoader` results on every platform.
- `ajpegli` has valid single-thread and `DataLoader` results, but skips one uncommon ImageNet JPEG in this matrix, so it is reported as a strict decoder rather than a zero-skip default.
- PyVips is reported for single-thread decode only; it is skipped in fork-based `DataLoader` benchmarks because of libvips threadpool deadlocks in this harness.

## GitAds Sponsored
Expand Down Expand Up @@ -219,6 +222,8 @@ output/

### Comprehensive codec libraries

- **ajpegli** — Python wrapper for Google's JPEGli decoder; supports both
path-based reads and in-memory byte decoding.
- **imagecodecs** — uses libjpeg-turbo 3.x; prebuilt ARM64 wheels
- **pyvips** — libvips bindings (bundled in wheels). Single-thread only;
the libvips threadpool deadlocks under fork-based PyTorch DataLoader,
Expand Down Expand Up @@ -254,7 +259,7 @@ output/
- Use the DataLoader benchmark for final decoder and `num_workers` selection.
- Start with OpenCV when you need a robust default that runs everywhere.
- Try `torchvision` when your pipeline already wants tensors and you can benchmark the target CPU.
- Try `simplejpeg` / `turbojpeg` / `jpeg4py` when maximum libjpeg-turbo-backed speed matters and your dataset policy handles uncommon JPEG modes.
- Try lower-level native decoders such as `simplejpeg`, `turbojpeg`, `jpeg4py`, `kornia-rs`, or `ajpegli` when maximum decode speed matters and your dataset policy handles uncommon JPEG modes.

### Choosing for pure decode speed

Expand Down
Binary file modified docs/assets/benchmarks/fig01_protocol_rank_change.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/benchmarks/fig02_amd_worker_delta.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/benchmarks/fig03_tensorflow_arm_penalty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/benchmarks/fig04_decoder_recommendation_summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 42 additions & 11 deletions docs/gcp_benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ tarball wins. To force the slow path for testing, rename or delete the tarball.

```bash
./gcp/run.sh \
--imagenet-bucket gs://my-bucket/imagenet/val \
--results-bucket gs://my-bucket/imread-results \
--imagenet-bucket gs://YOUR_BUCKET/imagenet/val \
--results-bucket gs://YOUR_BUCKET/imread-results \
--no-wait
```

Expand All @@ -72,27 +72,27 @@ What happens while you sleep:

1. A `c3-standard-8` VM boots on GCP (Intel Sapphire Rapids, 8 vCPU)
2. Downloads ImageNet from GCS to local disk (~30s within GCP, tarball fast path)
3. Runs all 11 library benchmarks in memory mode (1-thread + default-thread) — ~15-25 min
4. Runs all DataLoader benchmarks at 50k images, workers ∈ {0, 2, 4, 8} — ~25-45 min
5. Uploads results to `gs://my-bucket/imread-results/<run-id>/output/`
3. Runs all supported library benchmarks in memory mode (1-thread + default-thread) — ~15-25 min
4. Runs requested DataLoader benchmarks at 50k images, workers ∈ {0, 2, 4, 8} — ~25-45 min
5. Uploads results to `gs://YOUR_BUCKET/imread-results/<run-id>/output/`
6. **Deletes itself** — no orphaned VM, no ongoing billing

### In the morning

```bash
# Check if done (exit 0 = object exists)
gcloud storage objects describe gs://my-bucket/imread-results/<run-id>/DONE
gcloud storage objects describe gs://YOUR_BUCKET/imread-results/<run-id>/DONE

# Fetch results
gcloud storage cp --recursive gs://my-bucket/imread-results/<run-id>/output/ ./output/
gcloud storage cp --recursive gs://YOUR_BUCKET/imread-results/<run-id>/output/ ./output/
```

The exact commands are printed by `--no-wait` when you start the run.

### Watch the log mid-run (optional)

```bash
gcloud storage cat gs://my-bucket/imread-results/<run-id>/startup.log
gcloud storage cat gs://YOUR_BUCKET/imread-results/<run-id>/startup.log
```

This streams the full VM stdout, including per-library progress, without SSH.
Expand All @@ -105,13 +105,37 @@ If you want the script to block, download results, and exit all in one command:

```bash
./gcp/run.sh \
--imagenet-bucket gs://my-bucket/imagenet/val \
--results-bucket gs://my-bucket/imread-results
--imagenet-bucket gs://YOUR_BUCKET/imagenet/val \
--results-bucket gs://YOUR_BUCKET/imread-results
```

Progress is printed every 30 seconds. Results land in `./output/` when done.
Press Ctrl+C at any point — the VM is deleted immediately via a cleanup trap.

### Run one decoder

Use `--libs` when you only need new data for one decoder. For example, to add
`ajpegli` results without rerunning every library:

```bash
./gcp/run.sh \
--machine-type c4-standard-16 \
--smoke \
--libs ajpegli \
--imagenet-bucket gs://YOUR_BUCKET/imagenet/val \
--results-bucket gs://YOUR_BUCKET/imread-results
```

For the full paper platform matrix:

```bash
./gcp/run-many.sh \
--machine-types "c4-standard-16 c3d-standard-16 c4d-standard-16 c4a-standard-16 t2a-standard-16" \
--libs ajpegli \
--imagenet-bucket gs://YOUR_BUCKET/imagenet/val \
--results-bucket gs://YOUR_BUCKET/imread-results
```

---

## Options
Expand All @@ -123,6 +147,7 @@ Press Ctrl+C at any point — the VM is deleted immediately via a cleanup trap.
| `--results-bucket` | (required) | GCS bucket for results and logs |
| `--zone` | `us-central1-a` | GCP zone |
| `--machine-type` | `c3-standard-8` | VM type (Intel Sapphire Rapids, 8 vCPU, 32 GB) |
| `--libs` | `all` | Comma-separated decoder names, or `all` |
| `--num-images` | `50000` | Number of images per benchmark run (full ImageNet val) |
| `--num-runs` | `5` | Single-thread timed runs per library (see [Sample size](#sample-size))|
| `--dl-runs` | `3` | DataLoader timed runs per worker config |
Expand All @@ -141,7 +166,7 @@ All flags can also be set as environment variables (`IMAGENET_BUCKET`, `RESULTS_

## Venv cache

Cold installing 11 decoder libraries on the VM takes ~25 minutes. To avoid paying that on
Cold installing decoder libraries on the VM takes ~25 minutes. To avoid paying that on
every run, the first run on a given `(os, arch, deps)` combo tars `venvs/` and uploads it
to `gs://<your-bucket>/imread-cache/`. Subsequent runs pull and extract it in seconds.

Expand Down Expand Up @@ -282,6 +307,12 @@ ignored `_internal/papers/` workspace:
uv run --extra plot python -m tools.paper_assets --all
```

For the public arXiv preprint workspace specifically:

```bash
uv run --extra plot python -m tools.paper_assets --all --paper-dir _internal/papers/arxiv_preprint
```

Do not commit `_internal/` outputs. They are local manuscript artifacts; the
tracked source of truth is the benchmark JSON plus the generator code.

Expand Down
2 changes: 1 addition & 1 deletion docs/plotting_and_statistics.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,6 @@ throughput.
- "TensorFlow is always slow."
- "OpenCV is fastest."
- "PyVips is broken." Use "unsupported in this fork-based PyTorch DataLoader harness."
- "Strict libjpeg-turbo wrappers are wrong." Use "they require an explicit CMYK fallback policy."
- "Strict native JPEG decoders are wrong." Use "they require an explicit fallback policy for uncommon JPEG modes."
- "Rank 1 beats rank 2" when their uncertainty or practical gap makes them a top tier.
- Cross-platform absolute throughput comparisons without explaining CPU speed and microarchitecture.
16 changes: 10 additions & 6 deletions gcp/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# Optional:
# --zone GCP zone (default: us-central1-a)
# --machine-type GCP machine type (default: c3-standard-8)
# --libs comma-separated decoder names, or all (default: all)
# --num-images images to benchmark (default: 50000 = full ImageNet val)
# --num-runs single-thread timed runs per library (default: 5)
# --dl-runs DataLoader timed runs per worker config (default: 3)
Expand All @@ -32,14 +33,14 @@
# ./gcp/run.sh --upload-imagenet ~/imagenet/val --imagenet-bucket gs://my-bucket/imagenet/val
#
# # Smoke test on a new machine type (~10 min, ~$0.10)
# ./gcp/run.sh --machine-type c4-standard-16 --smoke \
# --imagenet-bucket gs://my-bucket/imagenet/val \
# --results-bucket gs://my-bucket/imread-results --no-wait
# ./gcp/run.sh --machine-type c4-standard-16 --smoke --libs ajpegli \
# --imagenet-bucket gs://YOUR_BUCKET/imagenet/val \
# --results-bucket gs://YOUR_BUCKET/imread-results --no-wait
#
# # Full run (blocks ~45-90 min depending on machine, fetches results when done)
# ./gcp/run.sh \
# --imagenet-bucket gs://my-bucket/imagenet/val \
# --results-bucket gs://my-bucket/imread-results
# --imagenet-bucket gs://YOUR_BUCKET/imagenet/val \
# --results-bucket gs://YOUR_BUCKET/imread-results
#
# # Fire and forget
# ./gcp/run.sh --imagenet-bucket gs://... --results-bucket gs://... --no-wait
Expand Down Expand Up @@ -68,6 +69,7 @@ NUM_IMAGES="${NUM_IMAGES:-50000}"
NUM_RUNS="${NUM_RUNS:-5}"
DL_RUNS="${DL_RUNS:-3}"
WORKERS="${WORKERS:-0 2 4 8}"
LIBS="${LIBS:-all}"
IMAGENET_BUCKET="${IMAGENET_BUCKET:-}"
RESULTS_BUCKET="${RESULTS_BUCKET:-}"
NO_WAIT=false
Expand All @@ -84,6 +86,7 @@ while [[ $# -gt 0 ]]; do
--results-bucket) RESULTS_BUCKET="$2"; shift 2 ;;
--zone) ZONE="$2"; shift 2 ;;
--machine-type) MACHINE_TYPE="$2"; shift 2 ;;
--libs) LIBS="$2"; shift 2 ;;
--num-images) NUM_IMAGES="$2"; shift 2 ;;
--num-runs) NUM_RUNS="$2"; shift 2 ;;
--dl-runs) DL_RUNS="$2"; shift 2 ;;
Expand Down Expand Up @@ -169,6 +172,7 @@ echo " Run ID : $RUN_NAME"
echo " Machine : $MACHINE_TYPE ($ZONE)"
echo " ImageNet : $IMAGENET_BUCKET"
echo " Results : $RUN_GCS"
echo " Libraries : $LIBS"
echo " Num images : $NUM_IMAGES"
echo " Single runs : $NUM_RUNS"
echo " DataLoader : $DL_RUNS runs × workers=[$WORKERS]"
Expand Down Expand Up @@ -257,7 +261,7 @@ gcloud compute instances create "$RUN_NAME" \
--image-project=ubuntu-os-cloud \
--boot-disk-size=60GB \
--boot-disk-type="$BOOT_DISK_TYPE" \
--metadata="results-bucket=$RUN_GCS,imagenet-bucket=$IMAGENET_BUCKET,num-images=$NUM_IMAGES,num-runs=$NUM_RUNS,dl-runs=$DL_RUNS,workers=$WORKERS,repo-tarball=$RUN_GCS/repo.tar.gz,cache-bucket=$CACHE_BUCKET,force-rebuild=$FORCE_REBUILD,keep-on-failure=$([[ $KEEP_ON_FAILURE == true ]] && echo 1 || echo 0)" \
--metadata="results-bucket=$RUN_GCS,imagenet-bucket=$IMAGENET_BUCKET,libs=$LIBS,num-images=$NUM_IMAGES,num-runs=$NUM_RUNS,dl-runs=$DL_RUNS,workers=$WORKERS,repo-tarball=$RUN_GCS/repo.tar.gz,cache-bucket=$CACHE_BUCKET,force-rebuild=$FORCE_REBUILD,keep-on-failure=$([[ $KEEP_ON_FAILURE == true ]] && echo 1 || echo 0)" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encode --libs metadata before passing comma-separated values

--libs is documented and parsed as a comma-separated list, but this value is interpolated directly into gcloud ... --metadata, whose syntax is KEY=VALUE,KEY=VALUE,.... When a user passes multiple decoders (for example --libs opencv,pillow), the comma inside $LIBS is treated as a metadata separator, so the second token is no longer part of libs and can make instance creation fail (or at best corrupt the metadata payload). This breaks the new multi-library selection path introduced by this commit.

Useful? React with 👍 / 👎.

--metadata-from-file=startup-script="$STARTUP_SCRIPT" \
--scopes=storage-rw,compute-rw \
--maintenance-policy=TERMINATE \
Expand Down
6 changes: 4 additions & 2 deletions gcp/vm_startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ meta() {
# ── Read metadata ──────────────────────────────────────────────────────────────
RESULTS_BUCKET=$(meta results-bucket)
IMAGENET_BUCKET=$(meta imagenet-bucket)
LIBS=$(meta libs 2>/dev/null || echo "all")
NUM_IMAGES=$(meta num-images)
REPO_TARBALL=$(meta repo-tarball)
# Optional metadata with sensible defaults so older callers still work.
Expand Down Expand Up @@ -137,6 +138,7 @@ echo " imread benchmark — VM startup"
echo " $(date -u)"
echo " Results bucket : $RESULTS_BUCKET"
echo " ImageNet : $IMAGENET_BUCKET"
echo " Libraries : $LIBS"
echo " Num images : $NUM_IMAGES"
echo " Repo tarball : $REPO_TARBALL"
echo "═══════════════════════════════════════════════════"
Expand Down Expand Up @@ -365,7 +367,7 @@ echo "[step 5] Done."
# encoded on the decoder classes themselves, no shell `case` games.
echo
echo "[step 6] Running benchmarks..."
echo " NUM_IMAGES=$NUM_IMAGES NUM_RUNS=$NUM_RUNS DL_RUNS=$DL_RUNS"
echo " LIBS=$LIBS NUM_IMAGES=$NUM_IMAGES NUM_RUNS=$NUM_RUNS DL_RUNS=$DL_RUNS"
echo " WORKERS=$WORKERS"
WORKERS_CSV=$(echo "$WORKERS" | tr ' ' ',')
# Partial-failure policy: per-decoder failures (e.g. turbojpeg crashing on a
Expand All @@ -380,7 +382,7 @@ WORKERS_CSV=$(echo "$WORKERS" | tr ' ' ',')
imread-benchmark run \
--data-dir "$IMAGENET_DIR" \
--output-dir output \
--libs all \
--libs "$LIBS" \
--mode both \
--num-images "$NUM_IMAGES" \
--num-runs "$NUM_RUNS" \
Expand Down
22 changes: 22 additions & 0 deletions imread_benchmark/decoders/ajpegli_decoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

import numpy as np

from imread_benchmark.decoders import BaseDecoder


class AJpegliDecoder(BaseDecoder):
"""ajpegli — Python bindings for Google's JPEGli decoder."""

name = "ajpegli"
package_name = "ajpegli"

def decode(self, data: bytes) -> np.ndarray:
import ajpegli

return ajpegli.imdecode(data, mode="RGB")

def decode_path(self, path: str) -> np.ndarray:
import ajpegli

return ajpegli.imread(path, mode="RGB")
57 changes: 57 additions & 0 deletions output/linux_AMD-EPYC-9B14/ajpegli_1t_results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"library": "ajpegli",
"mode": "memory",
"run_tag": "1t",
"requested_threads": 1,
"effective_threads": 1,
"system_info": {
"Python": "3.12.3",
"OS": "Linux",
"OS Version": "#17~24.04.1-Ubuntu SMP Thu May 7 15:12:38 UTC 2026",
"Machine": "x86_64",
"CPU": {
"brand_raw": "AMD EPYC 9B14",
"arch": "X86_64",
"hz_advertised_raw": "Unknown",
"count": 16
},
"ajpegli": "1.0.0"
},
"benchmark_results": {
"images_per_second_mean": 400.17318636034486,
"images_per_second_std": 0.0418143320963244,
"images_per_second_p50": 400.1734736537274,
"images_per_second_p90": 400.22015594016983,
"images_per_second_p99": 400.2213877410496,
"us_per_image_mean": 2498.91808097762,
"us_per_image_p50": 2498.9162596651927,
"us_per_image_p90": 2499.213334074683,
"us_per_image_p99": 2499.224782008442,
"raw_times_s": [
124.929381337,
124.95721351500003,
124.94331406699996,
124.95880347400009,
124.92831326099997
],
"raw_throughput_ips": [
400.21810293870345,
400.1289608942668,
400.1734736537274,
400.12386970721263,
400.22152460781405
],
"num_images_total": 50000,
"num_images_decoded": 49999,
"num_images_skipped": 1,
"skip_rate": 2e-05,
"skip_indices": [
19876
],
"skip_examples": [
"idx=19876: DecodeError: jpegli decode failed: ./lib/jpegli/color_transform.cc:549: Unsupported color transform 4 -> 2"
]
},
"num_images": 50000,
"num_runs": 5
}
Loading
Loading