Skip to content

Commit 70496fc

Browse files
committed
ci: layered Python base images for cross-matrix dedup
The 234-entry backend matrix runs the same apt-update + GPU SDK install + Python toolchain bootstrap into N independent registry-cache tags. Factor that shared work out into a tier-1+2 base image (lang × accel × ubuntu × cuda) built once per workflow run, then consumed by every backend that matches its tuple via BASE_IMAGE_PREBUILT. The matrix data moves to .github/backend-matrix.yaml so backend.yml can switch to fromJSON without duplicating the matrix. scripts/changed-backends.js reads the data file, derives the deduplicated bases-matrix, annotates each Python entry with the right base-image-prebuilt ref, and runs a collision check that fails loudly if a future matrix change makes two consumers want incompatible bases under the same tag-stem. PR builds tag with -pr<N> so end-to-end validation lives within one PR; master builds tag without the suffix. The base-images registry cache parallels the existing per-matrix-entry caches. Adding a new (accel, cuda) flavour is a backend-matrix.yaml edit; adding a new language tier is a Dockerfile.<lang> recipe + a slim of the consumer Dockerfile (script auto-detects via .docker/bases/). 10 distinct bases derive from the current 234 entries, replacing the inline bootstrap that previously ran into ~10 separate cache tags. Assisted-by: Claude:opus-4-7-1m [Claude Code]
1 parent a91b059 commit 70496fc

10 files changed

Lines changed: 4068 additions & 3460 deletions

File tree

.agents/ci-caching.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,134 @@ For ccache, the workflow exports `CMAKE_ARGS=… -DCMAKE_C_COMPILER_LAUNCHER=cca
101101

102102
GitHub Actions caches are limited to 10 GB per repo. Steady-state worst case: ~800 MB Go cache + ~2 GB brew Cellar + up to 2 GB ccache + ~1.5 GB × 5 python backends. If the cap is hit, prefer collapsing the per-backend Python keys into a shared `pyenv-darwin-shared-<week>` key (accepts more cross-backend churn for a smaller footprint) before reducing other caches.
103103

104+
## Layered base images (`localai-base`)
105+
106+
The registry-backed BuildKit cache deduplicates **within** a matrix entry's
107+
cache tag, but each matrix entry has its own tag — so the same `apt-get`,
108+
GPU SDK install, and language toolchain bootstrap runs into N different
109+
cache tags across the backend matrix. The `localai-base` images factor that
110+
shared work out of the per-backend builds.
111+
112+
### How it fits together
113+
114+
```
115+
.github/backend-matrix.yaml # raw matrix data (linux + darwin)
116+
117+
118+
backend.yml / backend_pr.yml
119+
├── derive-bases / generate-matrix
120+
│ scripts/changed-backends.js
121+
│ reads .github/backend-matrix.yaml
122+
│ (PR mode also reads changed files)
123+
│ emits:
124+
│ - matrix (annotated with base-image-prebuilt)
125+
│ - matrix-darwin
126+
│ - bases-matrix (deduplicated by tag-stem)
127+
128+
├── build-bases (matrix: bases-matrix)
129+
│ uses base_images.yml
130+
│ FROM .docker/bases/Dockerfile.<lang>
131+
│ pushes quay.io/go-skynet/localai-base:<stem>[-pr<N>]
132+
133+
└── backend-jobs (matrix: matrix; needs build-bases)
134+
uses backend_build.yml
135+
FROM ${BASE_IMAGE_PREBUILT}
136+
i.e. quay.io/go-skynet/localai-base:<stem>[-pr<N>]
137+
only the backend source COPY + `make` remain.
138+
```
139+
140+
The base image is **always** built before backends consume it, in the same
141+
workflow run. There is no cross-workflow dependency, no chicken-and-egg
142+
on first push, and no manual matrix to keep in sync — adding a backend
143+
matrix entry is just an edit to `.github/backend-matrix.yaml`.
144+
145+
### Tag scheme
146+
147+
`<stem>` is computed by `tagStem()` in `scripts/changed-backends.js` from
148+
the (lang, build-type, ubuntu, cuda, base-image) tuple. Arch is
149+
intentionally NOT in the stem — bases are built multi-arch when any
150+
consumer needs multi-arch, and single-arch otherwise (the `platforms`
151+
field on each base entry is the union of its consumers' platforms).
152+
153+
| Build-type | Stem template |
154+
|---|---|
155+
| `''` (CPU) | `<lang>-cpu-<ubuntu>[-<base-image-slug>]` |
156+
| `cublas` / `l4t` | `<lang>-<build-type>-<ubuntu>-cuda<major>.<minor>[-<base-image-slug>]` |
157+
| anything else (vulkan, hipblas, intel, sycl_*) | `<lang>-<build-type>-<ubuntu>[-<base-image-slug>]` |
158+
159+
The base-image slug is empty for the default `ubuntu:24.04` and a short
160+
parseable suffix otherwise (`jetpack-r36.4.0`, `rocm-7.2.1`,
161+
`oneapi-2025.3.2`, etc.).
162+
163+
| Event | Pushed tag |
164+
|---|---|
165+
| `push` (master/tag) | `:<stem>` |
166+
| `pull_request` | `:<stem>-pr<PR_NUMBER>` |
167+
168+
The cache for the base build itself lives at
169+
`quay.io/go-skynet/ci-cache:base-<stem>` (`mode=max,ignore-error=true`),
170+
parallel to the per-matrix-entry caches.
171+
172+
The script also runs a collision check across consumers of each stem: if
173+
two consumers map to the same stem but disagree on `base-image` or
174+
`skip-drivers` (and skip-drivers is meaningful for that build-type), the
175+
script fails loudly. Resolve by encoding the differing input in
176+
`tagStem()` rather than letting the dedup silently pick a winner.
177+
178+
### PR testability
179+
180+
PRs run the same pipeline as master: derive bases → build bases (tagged
181+
`-pr<N>`) → run filtered backend matrix consuming those `-pr<N>` tags.
182+
End-to-end validation always lives within the PR.
183+
184+
For PRs that only change `.docker/bases/Dockerfile.<lang>` (no backend
185+
source touched), `changed-backends.js` adds one canary backend matrix
186+
entry per (lang × build-type × arch × cuda × ubuntu) tuple to the filtered
187+
matrix so each base flavour gets exercised.
188+
189+
### Adding a new (accel × arch × cuda × lang) flavour
190+
191+
Just add the matrix entry to `.github/backend-matrix.yaml` for the new
192+
flavour. The bases matrix and the per-entry `base-image-prebuilt` are
193+
derived automatically by `scripts/changed-backends.js`. Nothing else to
194+
change.
195+
196+
### Adding a new language tier (e.g. golang)
197+
198+
1. Create `.docker/bases/Dockerfile.<lang>` mirroring `Dockerfile.python`
199+
(apt + accel install + lang-specific toolchain).
200+
2. Slim `backend/Dockerfile.<lang>` to `FROM ${BASE_IMAGE_PREBUILT}` plus
201+
the per-backend source COPY + build (no inline accel install).
202+
203+
The `langsWithBase` set in `scripts/changed-backends.js` is auto-detected
204+
from the `.docker/bases/` directory at script startup, so step 1 alone is
205+
enough for the script to start emitting bases (and annotating matrix
206+
entries with `base-image-prebuilt`) for that lang.
207+
208+
### Why not just rely on `mode=max` cache?
209+
210+
`mode=max` deduplicates at the layer level, but each matrix entry has its
211+
own cache tag (`cache<tag-suffix>`). A change that invalidates the GPU SDK
212+
layer in one backend does not invalidate it in any other; each entry pays
213+
the full cost on its next rebuild. The shared base image is built once per
214+
(accel × arch × cuda × lang), then pulled by every backend that consumes
215+
it — that's the actual cross-matrix dedup.
216+
217+
### Local builds
218+
219+
`backend/Dockerfile.python` requires `BASE_IMAGE_PREBUILT` (no inline
220+
fallback). For local development:
221+
222+
```bash
223+
# Build a base flavour locally
224+
make backend-image-base BUILD_TYPE=cublas CUDA_MAJOR_VERSION=12 CUDA_MINOR_VERSION=9
225+
226+
# Build a backend on top of it
227+
make backend-image BACKEND=mlx-vlm BUILD_TYPE=cublas CUDA_MAJOR_VERSION=12 CUDA_MINOR_VERSION=9
228+
```
229+
230+
Or pull a pre-built base from quay if it exists for your target tuple.
231+
104232
## Touching the cache pipeline
105233

106234
When changing `image_build.yml`, `backend_build.yml`, or any of the `backend/Dockerfile.*` files:
@@ -109,3 +237,4 @@ When changing `image_build.yml`, `backend_build.yml`, or any of the `backend/Doc
109237
2. **Keep `tag-suffix` unique per matrix entry** — it's the cache namespace. Two matrix entries sharing a tag-suffix would clobber each other's cache.
110238
3. **Keep `cache-to` gated on `github.event_name != 'pull_request'`** — PRs must not write.
111239
4. **Keep `ignore-error=true` on `cache-to`** — quay registry hiccups must not fail builds.
240+
5. **`tagStem()` in `scripts/changed-backends.js` is the single source of truth for base image tags.** The matrix entries are annotated with `base-image-prebuilt` in the same script run; backend-jobs reads the value as-is. There's no parallel YAML expression to keep in sync. Adding a new dimension to the stem (e.g. a slug for a new base-image variant) is a script change only.

.docker/bases/Dockerfile.python

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Shared Python + accelerator base image.
2+
#
3+
# Built once per (build-type, arch, ubuntu-version, cuda-version) combination
4+
# by .github/workflows/base_images_python.yml and pushed to
5+
# quay.io/go-skynet/localai-base:<tag-stem>[-pr<N>]. Consumed by
6+
# backend/Dockerfile.python via the BASE_IMAGE_PREBUILT build-arg.
7+
#
8+
# Keep the install steps below in lock-step with backend/Dockerfile.python's
9+
# accel-inline stage until the inline fallback is removed. See
10+
# .agents/ci-caching.md for the migration plan.
11+
12+
ARG BASE_IMAGE=ubuntu:24.04
13+
ARG APT_MIRROR=""
14+
ARG APT_PORTS_MIRROR=""
15+
16+
FROM ${BASE_IMAGE}
17+
18+
ARG BUILD_TYPE
19+
ENV BUILD_TYPE=${BUILD_TYPE}
20+
ARG CUDA_MAJOR_VERSION
21+
ARG CUDA_MINOR_VERSION
22+
ARG SKIP_DRIVERS=false
23+
ENV CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION}
24+
ENV CUDA_MINOR_VERSION=${CUDA_MINOR_VERSION}
25+
ENV DEBIAN_FRONTEND=noninteractive
26+
ARG TARGETARCH
27+
ARG TARGETVARIANT
28+
ARG UBUNTU_VERSION=2404
29+
ARG APT_MIRROR
30+
ARG APT_PORTS_MIRROR
31+
32+
LABEL org.opencontainers.image.source="https://github.com/mudler/LocalAI"
33+
LABEL org.opencontainers.image.description="LocalAI Python+accelerator base image"
34+
LABEL org.localai.base.lang="python"
35+
36+
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
37+
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
38+
apt-get update && \
39+
apt-get install -y --no-install-recommends \
40+
build-essential \
41+
ccache \
42+
ca-certificates \
43+
espeak-ng \
44+
curl \
45+
libssl-dev \
46+
git wget \
47+
git-lfs \
48+
unzip clang \
49+
upx-ucl \
50+
curl python3-pip \
51+
python-is-python3 \
52+
python3-dev llvm \
53+
libnuma1 libgomp1 \
54+
python3-venv make cmake && \
55+
apt-get clean && \
56+
rm -rf /var/lib/apt/lists/*
57+
58+
RUN <<EOT bash
59+
if [ "${UBUNTU_VERSION}" = "2404" ]; then
60+
pip install --break-system-packages --user --upgrade pip
61+
else
62+
pip install --upgrade pip
63+
fi
64+
EOT
65+
66+
# Cuda
67+
ENV PATH=/usr/local/cuda/bin:${PATH}
68+
69+
# HipBLAS requirements
70+
ENV PATH=/opt/rocm/bin:${PATH}
71+
72+
# Vulkan requirements
73+
RUN <<EOT bash
74+
if [ "${BUILD_TYPE}" = "vulkan" ] && [ "${SKIP_DRIVERS}" = "false" ]; then
75+
apt-get update && \
76+
apt-get install -y --no-install-recommends \
77+
software-properties-common pciutils wget gpg-agent && \
78+
apt-get install -y libglm-dev cmake libxcb-dri3-0 libxcb-present0 libpciaccess0 \
79+
libpng-dev libxcb-keysyms1-dev libxcb-dri3-dev libx11-dev g++ gcc \
80+
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
81+
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
82+
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
83+
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
84+
if [ "amd64" = "$TARGETARCH" ]; then
85+
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
86+
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
87+
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
88+
mkdir -p /opt/vulkan-sdk && \
89+
mv 1.4.335.0 /opt/vulkan-sdk/ && \
90+
cd /opt/vulkan-sdk/1.4.335.0 && \
91+
./vulkansdk --no-deps --maxjobs \
92+
vulkan-loader \
93+
vulkan-validationlayers \
94+
vulkan-extensionlayer \
95+
vulkan-tools \
96+
shaderc && \
97+
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
98+
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
99+
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
100+
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
101+
rm -rf /opt/vulkan-sdk
102+
fi
103+
if [ "arm64" = "$TARGETARCH" ]; then
104+
mkdir vulkan && cd vulkan && \
105+
curl -L -o vulkan-sdk.tar.xz https://github.com/mudler/vulkan-sdk-arm/releases/download/1.4.335.0/vulkansdk-ubuntu-24.04-arm-1.4.335.0.tar.xz && \
106+
tar -xvf vulkan-sdk.tar.xz && \
107+
rm vulkan-sdk.tar.xz && \
108+
cd 1.4.335.0 && \
109+
cp -rfv aarch64/bin/* /usr/bin/ && \
110+
cp -rfv aarch64/lib/* /usr/lib/aarch64-linux-gnu/ && \
111+
cp -rfv aarch64/include/* /usr/include/ && \
112+
cp -rfv aarch64/share/* /usr/share/ && \
113+
cd ../.. && \
114+
rm -rf vulkan
115+
fi
116+
ldconfig && \
117+
apt-get clean && \
118+
rm -rf /var/lib/apt/lists/*
119+
fi
120+
EOT
121+
122+
# CuBLAS requirements
123+
RUN <<EOT bash
124+
if ( [ "${BUILD_TYPE}" = "cublas" ] || [ "${BUILD_TYPE}" = "l4t" ] ) && [ "${SKIP_DRIVERS}" = "false" ]; then
125+
apt-get update && \
126+
apt-get install -y --no-install-recommends \
127+
software-properties-common pciutils
128+
if [ "amd64" = "$TARGETARCH" ]; then
129+
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/x86_64/cuda-keyring_1.1-1_all.deb
130+
fi
131+
if [ "arm64" = "$TARGETARCH" ]; then
132+
if [ "${CUDA_MAJOR_VERSION}" = "13" ]; then
133+
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/sbsa/cuda-keyring_1.1-1_all.deb
134+
else
135+
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/arm64/cuda-keyring_1.1-1_all.deb
136+
fi
137+
fi
138+
dpkg -i cuda-keyring_1.1-1_all.deb && \
139+
rm -f cuda-keyring_1.1-1_all.deb && \
140+
apt-get update && \
141+
apt-get install -y --no-install-recommends \
142+
cuda-nvcc-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
143+
libcufft-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
144+
libcurand-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
145+
libcublas-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
146+
libcusparse-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
147+
libcusolver-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION}
148+
if [ "${CUDA_MAJOR_VERSION}" = "13" ] && [ "arm64" = "$TARGETARCH" ]; then
149+
apt-get install -y --no-install-recommends \
150+
libcufile-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} libcudnn9-cuda-${CUDA_MAJOR_VERSION} cuda-cupti-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} libnvjitlink-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION}
151+
fi
152+
apt-get clean && \
153+
rm -rf /var/lib/apt/lists/*
154+
fi
155+
EOT
156+
157+
158+
# https://github.com/NVIDIA/Isaac-GR00T/issues/343
159+
RUN <<EOT bash
160+
if [ "${BUILD_TYPE}" = "cublas" ] && [ "${TARGETARCH}" = "arm64" ]; then
161+
wget https://developer.download.nvidia.com/compute/cudss/0.6.0/local_installers/cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0_0.6.0-1_arm64.deb && \
162+
dpkg -i cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0_0.6.0-1_arm64.deb && \
163+
cp /var/cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0/cudss-*-keyring.gpg /usr/share/keyrings/ && \
164+
apt-get update && apt-get -y install cudss cudss-cuda-${CUDA_MAJOR_VERSION} && \
165+
wget https://developer.download.nvidia.com/compute/nvpl/25.5/local_installers/nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5_1.0-1_arm64.deb && \
166+
dpkg -i nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5_1.0-1_arm64.deb && \
167+
cp /var/nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5/nvpl-*-keyring.gpg /usr/share/keyrings/ && \
168+
apt-get update && apt-get install -y nvpl
169+
fi
170+
EOT
171+
172+
# If we are building with clblas support, we need the libraries for the builds
173+
RUN if [ "${BUILD_TYPE}" = "clblas" ] && [ "${SKIP_DRIVERS}" = "false" ]; then \
174+
apt-get update && \
175+
apt-get install -y --no-install-recommends \
176+
libclblast-dev && \
177+
apt-get clean && \
178+
rm -rf /var/lib/apt/lists/* \
179+
; fi
180+
181+
RUN if [ "${BUILD_TYPE}" = "hipblas" ] && [ "${SKIP_DRIVERS}" = "false" ]; then \
182+
apt-get update && \
183+
apt-get install -y --no-install-recommends \
184+
hipblas-dev \
185+
hipblaslt-dev \
186+
rocblas-dev && \
187+
apt-get clean && \
188+
rm -rf /var/lib/apt/lists/* && \
189+
# I have no idea why, but the ROCM lib packages don't trigger ldconfig after they install, which results in local-ai and others not being able
190+
# to locate the libraries. We run ldconfig ourselves to work around this packaging deficiency
191+
ldconfig \
192+
; fi
193+
194+
RUN if [ "${BUILD_TYPE}" = "hipblas" ]; then \
195+
ln -s /opt/rocm-**/lib/llvm/lib/libomp.so /usr/lib/libomp.so \
196+
; fi
197+
198+
# Install uv as a system package
199+
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/bin sh
200+
ENV PATH="/root/.cargo/bin:${PATH}"
201+
# Increase timeout for uv installs behind slow networks
202+
ENV UV_HTTP_TIMEOUT=180
203+
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
204+
205+
# Install grpcio-tools (the version in 22.04 is too old)
206+
RUN <<EOT bash
207+
if [ "${UBUNTU_VERSION}" = "2404" ]; then
208+
pip install --break-system-packages --user grpcio-tools==1.71.0 grpcio==1.71.0
209+
else
210+
pip install grpcio-tools==1.71.0 grpcio==1.71.0
211+
fi
212+
EOT

0 commit comments

Comments
 (0)