1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-08 07:44:57 +00:00
terraform-playground/pymage/docs/ai-image-comparison.md
2026-06-11 02:17:01 +00:00

118 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Large AI images: where the size lives, and what pymage saves
A common worry with GPU/AI Python images is that they're huge and slow to
rebuild and ship. This study answers two questions with real numbers:
1. In a CUDA/PyTorch image, is the size in the **base image** or in the **Python
wheels**?
2. How do **incremental rebuilds** (bump one dependency, or edit app code)
compare between a normal `uv`/`pip` Dockerfile and pymage?
**TL;DR:** for modern PyTorch installs the CUDA runtime ships *inside the
wheels*, so the dependencies — not the base — are almost the entire image
(2945 MB of 2988 MB here, **98.5%**). That plays directly to pymage's strength:
each wheel is its own content-addressed layer, so bumping one dependency
re-uploads **one layer (~11 MB)** while the ~2.9 GB of `torch`/CUDA layers are
deduplicated by the registry. A `uv sync` Dockerfile puts the whole environment
in a single layer, so the same one-line bump re-pushes the **entire ~2.9 GB**.
## Where does the size come from?
Modern `torch` wheels on PyPI are the CUDA build: `torch` itself plus a stack of
`nvidia-*` CUDA-runtime wheels (cuDNN, cuBLAS, cuFFT, NCCL, …) and `triton`.
These are pulled as ordinary wheel dependencies — the CUDA libraries are *in the
wheels*, not in the base image.
A representative `uv` project (`torch`, `transformers`, `numpy`, `pillow`,
`fastapi`; 62 packages resolved) built on a `python:3.12-slim` base, pushed to a
local registry (compressed/registry sizes):
| Bucket | Compressed size | Share |
| --- | --- | --- |
| **Dependency (wheel) layers** | **2945 MB** | **98.5%** |
| Base image (`python:3.12-slim`, 5 layers) | 43 MB | 1.5% |
| **Image total** | **2988 MB** | 100% |
Largest single layers (one wheel each):
| Wheel | Layer (compressed) |
| --- | --- |
| `torch==2.12.0` | 564 MB |
| `nvidia-cublas` | 434 MB |
| `nvidia-cudnn-cu13` | 374 MB |
| `triton` | 234 MB |
| `nvidia-cufft` | 229 MB |
| `nvidia-nccl-cu13` | 208 MB |
| `nvidia-cusolver` | 204 MB |
| `nvidia-cusparselt-cu13` | 174 MB |
So the base is a rounding error; the wheels (dominated by `torch` + the CUDA
stack, ~2.7 GB) are the image. (Even if you *do* use a multi-GB
`nvidia/cuda:*-runtime` base, that base is stable and shared/cached — the churn
that costs you on every rebuild is the dependency layer.)
## Incremental rebuilds (the real win)
Built `v1`, then made two one-line changes and rebuilt, pushing to the same
registry and diffing the manifests. "Bytes re-uploaded" = the total size of
layers whose digest changed (every other layer is already present and is
skipped via a registry `HEAD`).
| Change | Layers changed | Bytes re-uploaded | Rebuild wall time |
| --- | --- | --- | --- |
| Bump one dep (`transformers` 5.11.0 → 5.10.2) | **1 / 65** | **11.2 MB** | ~6 s |
| Edit one app source file | **1 / 65** | **0.3 KB** | ~6 s |
On the dependency bump, the push moved exactly two blobs: the new
`transformers` layer (11 MB) and the image config; the other 63 layers —
including all ~2.9 GB of `torch`/CUDA — were already present and skipped.
**99.6% of the image (2977 MB) was deduplicated.**
### Contrast with a `uv`/`pip` Dockerfile
A typical CUDA Dockerfile installs the environment in a single step:
```dockerfile
RUN --mount=... uv sync --frozen --no-install-project # ONE layer: ~2.9 GB
COPY . /app
```
- **Editing app code** invalidates the `COPY` layer only, so it's cheap in both
approaches (assuming the multi-stage / deps-first ordering above). With a naive
`RUN pip install … && COPY .` in one layer, even an app edit re-pushes
everything.
- **Bumping one dependency** re-runs `uv sync`, which rewrites the whole
virtualenv into a **single new image layer**. That entire layer — all ~2.9 GB,
including `torch` and every CUDA wheel that *didn't* change — becomes a new blob
that must be re-pushed and later re-pulled.
| | pymage | `uv sync` Dockerfile |
| --- | --- | --- |
| Bytes re-pushed on a one-dep bump | **~11 MB** (the changed wheel) | **~2.9 GB** (whole venv layer) |
| Bytes a consumer re-pulls for the update | ~11 MB | ~2.9 GB |
| Layer granularity | one layer per wheel (auto-bucketed to ≤127) | one layer per `RUN` |
That's roughly a **260× reduction** in data moved for a routine dependency bump,
and the savings repeat on every `docker pull` of the new revision across every
node/replica. Build time drops too: pymage reuses the cached `torch`/CUDA layers
and only fetches + layers the changed wheel (~6 s here), whereas re-running
`uv sync` re-materializes and re-exports the multi-GB environment.
You can't easily get pymage's granularity from a Dockerfile: splitting `torch`
into its own layer means fragile, hand-maintained multi-`RUN` install ordering,
and even then a patch to one CUDA wheel re-pushes its whole `RUN` group. pymage
does per-wheel layering automatically and deterministically.
## Method & caveats
- Real `uv` project locked with `uv lock` (universal lock); pymage resolved the
linux/amd64 runtime closure and pulled wheels from the lock URLs. Sizes are
compressed (registry) bytes from the pushed manifests.
- Built on `python:3.12-slim` to isolate the dependency story. In production
you'd typically run GPU images on a CUDA base (or rely on the host NVIDIA
driver + container toolkit, since the `torch`/`nvidia-*` wheels already bundle
the CUDA *runtime* libraries). The base choice doesn't change the
layer-reuse result — the dependency layers are what churn.
- No GPU was needed: this measures build/layout/push behavior, not training.
- The first (cold) build downloads the full ~2.8 GB of wheels once (~90 s here);
subsequent builds reuse the wheel and layer caches.