Skip to content

Commit 606128e

Browse files
authored
feat(vulkan): make Vulkan backends self-contained on the GPU (#10404)
Vulkan backends bundled their own loader and ICD manifests but neither the Mesa driver the manifests point at nor a way to make the loader find them, so on a runtime base image without Mesa the loader enumerated zero devices and the GPU silently fell back to CPU (only NVIDIA worked, since its ICD is injected by the container toolkit). - scripts/build/package-gpu-libs.sh: for each installed ICD manifest, bundle the driver .so its library_path names — no hard-coded, platform-dependent soname list — plus that driver's ldd dependencies, skipping manifests whose driver isn't installed. Rewrite each library_path to a bare soname so the bundled driver resolves via the LD_LIBRARY_PATH run.sh already sets. - .docker/install-base-deps.sh, backend/Dockerfile.golang, backend/Dockerfile.python: install mesa-vulkan-drivers in every Vulkan builder so the driver + manifests exist to be packaged (the LunarG SDK ships only the loader and shader tooling). - pkg/model/process.go: when a backend ships vulkan/icd.d/, point the loader at it via VK_DRIVER_FILES/VK_ICD_FILENAMES at launch (no-op otherwise). Covered by pkg/model/process_vulkan_test.go. - backend/go/parakeet-cpp/package.sh: complete the L0 stub (was missing the libc-family ldd walk + GPU-lib packaging) by mirroring whisper, so the vulkan-parakeet image actually bundles its GPU runtime. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 59c7ad5 commit 606128e

7 files changed

Lines changed: 262 additions & 12 deletions

File tree

.docker/install-base-deps.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ if [ "${BUILD_TYPE:-}" = "vulkan" ] && [ "${SKIP_DRIVERS:-false}" = "false" ]; t
7070
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
7171
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
7272
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
73+
# Mesa Vulkan ICD drivers (ANV/RADV/lavapipe + Arm SoC) and their ICD
74+
# manifests. The LunarG SDK below only provides the loader and shader
75+
# tooling, not hardware drivers — without Mesa the packaged Vulkan backend
76+
# would ship a loader that finds no GPU. package-gpu-libs.sh bundles these
77+
# .so files plus their deps into the backend so it stays self-contained.
78+
apt-get install -y mesa-vulkan-drivers libdrm2
7379
if [ "amd64" = "${TARGETARCH:-}" ]; then
7480
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz"
7581
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz

backend/Dockerfile.golang

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ RUN <<EOT bash
6565
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
6666
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
6767
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
68-
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
68+
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils && \
69+
apt-get install -y mesa-vulkan-drivers libdrm2
70+
# Mesa Vulkan ICD drivers (ANV/RADV/lavapipe) + their manifests. The
71+
# LunarG SDK below only provides the loader and shader tooling, not
72+
# hardware drivers — without Mesa, package-gpu-libs.sh has no ICD to
73+
# bundle and the packaged backend finds no GPU at runtime.
6974
if [ "amd64" = "$TARGETARCH" ]; then
7075
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
7176
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \

backend/Dockerfile.python

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ RUN <<EOT bash
6666
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
6767
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
6868
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
69-
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
69+
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils && \
70+
apt-get install -y mesa-vulkan-drivers libdrm2
71+
# Mesa Vulkan ICD drivers (ANV/RADV/lavapipe) + their manifests. The
72+
# LunarG SDK below only provides the loader and shader tooling, not
73+
# hardware drivers — without Mesa, package-gpu-libs.sh has no ICD to
74+
# bundle and the packaged backend finds no GPU at runtime.
7075
if [ "amd64" = "$TARGETARCH" ]; then
7176
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
7277
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \

backend/go/parakeet-cpp/package.sh

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,68 @@
11
#!/bin/bash
22
#
3-
# L0 packaging stub: copy the binary, run.sh and libparakeet.so* into
4-
# package/. The full ldd walk (libc, libstdc++, libgomp, GPU runtimes,
5-
# arch detection) lands in L3, mirroring backend/go/whisper/package.sh.
3+
# Bundle the parakeet-cpp-grpc binary, libparakeet.so, the core runtime
4+
# libs (libc/libstdc++/libgomp + ld.so) and the GPU runtime for the active
5+
# BUILD_TYPE so the package is self-contained. Mirrors
6+
# backend/go/whisper/package.sh; run.sh routes the (CGO_ENABLED=0) binary
7+
# through lib/ld.so so the packaged libc is used instead of the host's.
68

79
set -e
810

911
CURDIR=$(dirname "$(realpath "$0")")
12+
REPO_ROOT="${CURDIR}/../../.."
1013

1114
mkdir -p "$CURDIR/package/lib"
1215

1316
cp -avf "$CURDIR/parakeet-cpp-grpc" "$CURDIR/package/"
1417
cp -avf "$CURDIR/run.sh" "$CURDIR/package/"
1518

16-
# libparakeet.so + any soname symlinks (libparakeet.so.X, libparakeet.so.X.Y).
19+
# libparakeet.so + any soname symlinks (libparakeet.so.X[.Y]). purego.Dlopen
20+
# resolves it via LD_LIBRARY_PATH, which run.sh points at lib/.
1721
cp -avf "$CURDIR"/libparakeet.so* "$CURDIR/package/lib/" 2>/dev/null || {
1822
echo "ERROR: libparakeet.so not found in $CURDIR, run 'make' first" >&2
1923
exit 1
2024
}
2125

22-
echo "L0 package layout (full ldd walk lands in L3):"
26+
# Detect architecture and copy the core runtime libs libparakeet.so links
27+
# against, plus the matching dynamic loader as lib/ld.so.
28+
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
29+
echo "Detected x86_64 architecture, copying x86_64 libraries..."
30+
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
31+
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
32+
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
33+
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
34+
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
35+
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
36+
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
37+
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
38+
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
39+
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
40+
echo "Detected ARM64 architecture, copying ARM64 libraries..."
41+
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
42+
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
43+
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
44+
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
45+
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
46+
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
47+
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
48+
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
49+
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
50+
elif [ "$(uname -s)" = "Darwin" ]; then
51+
echo "Detected Darwin"
52+
else
53+
echo "Error: Could not detect architecture"
54+
exit 1
55+
fi
56+
57+
# Package GPU libraries (CUDA/ROCm/Intel/Vulkan loader + ICDs + drivers)
58+
# based on BUILD_TYPE so the backend can reach the GPU without the runtime
59+
# base image shipping those drivers.
60+
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
61+
if [ -f "$GPU_LIB_SCRIPT" ]; then
62+
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
63+
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
64+
package_gpu_libs
65+
fi
66+
67+
echo "Packaging completed successfully"
2368
ls -liah "$CURDIR/package/" "$CURDIR/package/lib/"

pkg/model/process.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,20 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string
154154
return nil, err
155155
}
156156

157+
env := os.Environ()
158+
// Vulkan backends are self-contained: they bundle their own loader and
159+
// Mesa driver .so files in lib/ plus the matching ICD manifests in
160+
// vulkan/icd.d/. Point the loader at those manifests so it doesn't rely on
161+
// the runtime base image shipping a Vulkan driver (it carries the
162+
// SYCL/Level-Zero stack instead, so the default ICD search path is empty
163+
// and the GPU would silently fall back to CPU). No-op for other backends.
164+
env = append(env, vulkanICDEnv(workDir)...)
165+
157166
grpcControlProcess := process.New(
158167
process.WithTemporaryStateDir(),
159168
process.WithName(filepath.Base(grpcProcess)),
160169
process.WithArgs(append(args, []string{"--addr", serverAddress}...)...),
161-
process.WithEnvironment(os.Environ()...),
170+
process.WithEnvironment(env...),
162171
process.WithWorkDir(workDir),
163172
)
164173

@@ -249,3 +258,38 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string
249258

250259
return grpcControlProcess, nil
251260
}
261+
262+
// vulkanICDEnv returns environment overrides that point the Vulkan loader at
263+
// the ICD manifests a backend bundles in <workDir>/vulkan/icd.d. Vulkan
264+
// backends ship a self-contained stack — their own loader and Mesa driver .so
265+
// files in lib/ (resolved via the LD_LIBRARY_PATH that run.sh sets) plus the
266+
// matching ICD manifests — so the loader must be told where those manifests
267+
// live; its default search path (/usr/share/vulkan/icd.d, /etc/vulkan/icd.d)
268+
// is empty on the runtime base image. Returns nil when the directory holds no
269+
// manifests (CPU/CUDA/SYCL builds), leaving the host's Vulkan setup untouched.
270+
func vulkanICDEnv(workDir string) []string {
271+
icdDir := filepath.Join(workDir, "vulkan", "icd.d")
272+
entries, err := os.ReadDir(icdDir)
273+
if err != nil {
274+
return nil
275+
}
276+
277+
manifests := make([]string, 0, len(entries))
278+
for _, e := range entries {
279+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
280+
continue
281+
}
282+
manifests = append(manifests, filepath.Join(icdDir, e.Name()))
283+
}
284+
if len(manifests) == 0 {
285+
return nil
286+
}
287+
288+
list := strings.Join(manifests, string(os.PathListSeparator))
289+
// VK_DRIVER_FILES is the current loader variable; VK_ICD_FILENAMES is its
290+
// deprecated alias, set too so older bundled loaders still pick it up.
291+
return []string{
292+
"VK_DRIVER_FILES=" + list,
293+
"VK_ICD_FILENAMES=" + list,
294+
}
295+
}

pkg/model/process_vulkan_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package model
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
var _ = Describe("vulkanICDEnv", func() {
13+
It("returns nil when the backend ships no vulkan/icd.d (CPU/CUDA/SYCL builds)", func() {
14+
Expect(vulkanICDEnv(GinkgoT().TempDir())).To(BeNil())
15+
})
16+
17+
It("returns nil when icd.d exists but holds no .json manifests", func() {
18+
work := GinkgoT().TempDir()
19+
icdDir := filepath.Join(work, "vulkan", "icd.d")
20+
Expect(os.MkdirAll(icdDir, 0o755)).To(Succeed())
21+
Expect(os.WriteFile(filepath.Join(icdDir, "README.txt"), []byte("not a manifest"), 0o644)).To(Succeed())
22+
// A directory whose name ends in .json must be ignored.
23+
Expect(os.MkdirAll(filepath.Join(icdDir, "nested.json"), 0o755)).To(Succeed())
24+
25+
Expect(vulkanICDEnv(work)).To(BeNil())
26+
})
27+
28+
It("points VK_DRIVER_FILES/VK_ICD_FILENAMES at the bundled manifests", func() {
29+
work := GinkgoT().TempDir()
30+
icdDir := filepath.Join(work, "vulkan", "icd.d")
31+
Expect(os.MkdirAll(icdDir, 0o755)).To(Succeed())
32+
for _, name := range []string{"intel_icd.json", "lvp_icd.json"} {
33+
Expect(os.WriteFile(filepath.Join(icdDir, name), []byte("{}"), 0o644)).To(Succeed())
34+
}
35+
36+
env := vulkanICDEnv(work)
37+
Expect(env).To(HaveLen(2))
38+
39+
got := map[string]string{}
40+
for _, kv := range env {
41+
k, v, ok := strings.Cut(kv, "=")
42+
Expect(ok).To(BeTrue(), "malformed env entry %q", kv)
43+
got[k] = v
44+
}
45+
46+
for _, key := range []string{"VK_DRIVER_FILES", "VK_ICD_FILENAMES"} {
47+
Expect(got).To(HaveKey(key))
48+
// Both manifests must be listed as absolute paths, joined by the
49+
// OS path-list separator the Vulkan loader expects.
50+
parts := strings.Split(got[key], string(os.PathListSeparator))
51+
Expect(parts).To(HaveLen(2))
52+
for _, p := range parts {
53+
Expect(filepath.IsAbs(p)).To(BeTrue(), "%s entry %q must be absolute", key, p)
54+
Expect(p).To(HaveSuffix(".json"))
55+
}
56+
}
57+
})
58+
})

scripts/build/package-gpu-libs.sh

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,38 @@ copy_libs_glob() {
109109
done
110110
}
111111

112+
# Returns success for the core runtime libs the base image and package.sh
113+
# already provide. We must NOT bundle our own copies of these — a second libc
114+
# or libstdc++ on LD_LIBRARY_PATH clashes with the loader and the rest of the
115+
# process — so they're skipped when pulling in a driver's transitive deps.
116+
is_core_lib() {
117+
case "$1" in
118+
ld-linux*|ld.so|libc.so.*|libm.so.*|libdl.so.*|libpthread.so.*|librt.so.*|\
119+
libgcc_s.so.*|libstdc++.so.*|libresolv.so.*|libutil.so.*|linux-vdso.so.*)
120+
return 0 ;;
121+
esac
122+
return 1
123+
}
124+
125+
# Copy the shared-library dependencies of an ELF file into TARGET_LIB_DIR.
126+
# Used to make a bundled GPU driver self-contained: e.g. the Mesa Vulkan ICDs
127+
# pull in libdrm, libexpat and (for RADV/lavapipe) libLLVM, none of which the
128+
# runtime base image is guaranteed to have. Core libc-family deps are skipped.
129+
copy_elf_deps() {
130+
local elf="$1"
131+
[ -e "$elf" ] || return 0
132+
command -v ldd >/dev/null 2>&1 || return 0
133+
134+
# ldd lines look like: "<TAB>libfoo.so.1 => /path/to/libfoo.so.1 (0x..)".
135+
# Take the resolved absolute path (field 3) and skip vdso/static entries.
136+
while read -r dep; do
137+
if is_core_lib "$(basename "$dep")"; then
138+
continue
139+
fi
140+
copy_lib "$dep"
141+
done < <(ldd "$elf" 2>/dev/null | awk '/=>/ && $3 ~ /^\// {print $3}')
142+
}
143+
112144
# Package NVIDIA CUDA libraries
113145
package_cuda_libs() {
114146
echo "Packaging CUDA libraries for BUILD_TYPE=${BUILD_TYPE}..."
@@ -284,7 +316,7 @@ package_vulkan_libs() {
284316
"/usr/local/lib"
285317
)
286318

287-
# Core Vulkan runtime libraries
319+
# Core Vulkan runtime: the loader plus the shader tooling shipped by the SDK.
288320
local vulkan_libs=(
289321
"libvulkan.so*"
290322
"libshaderc_shared.so*"
@@ -301,10 +333,63 @@ package_vulkan_libs() {
301333
fi
302334
done
303335

304-
# Copy Vulkan ICD files
336+
# Bundle the ICD drivers. Rather than hard-code Mesa's (platform- and
337+
# version-dependent) driver sonames, treat each installed ICD manifest as
338+
# the source of truth: every /usr/share/vulkan/icd.d/*.json names the exact
339+
# driver .so it needs in its "library_path". So we copy whatever drivers
340+
# the manifests reference (libvulkan_intel/radeon/lvp/... on amd64, the SoC
341+
# drivers on arm64, ...) plus each driver's transitive deps, and skip any
342+
# manifest whose driver isn't actually installed. The loader picks the
343+
# right driver for the GPU at runtime.
305344
if [ -d "/usr/share/vulkan/icd.d" ]; then
306-
mkdir -p "$TARGET_LIB_DIR/../vulkan/icd.d"
307-
cp -arfL /usr/share/vulkan/icd.d/* "$TARGET_LIB_DIR/../vulkan/icd.d/" 2>/dev/null || true
345+
local icd_dest="$TARGET_LIB_DIR/../vulkan/icd.d"
346+
mkdir -p "$icd_dest"
347+
348+
local manifest driver driver_base resolved lib_path
349+
for manifest in /usr/share/vulkan/icd.d/*.json; do
350+
[ -e "$manifest" ] || continue
351+
352+
# Pull the driver path out of "library_path": "<path-or-soname>".
353+
driver=$(sed -nE 's/.*"library_path"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$manifest" | head -n1)
354+
[ -n "$driver" ] || continue
355+
driver_base=$(basename "$driver")
356+
357+
# Resolve to an absolute path: honour an absolute library_path,
358+
# else look in the standard lib dirs, else fall back to ldconfig.
359+
resolved=""
360+
case "$driver" in
361+
/*) [ -e "$driver" ] && resolved="$driver" ;;
362+
esac
363+
if [ -z "$resolved" ]; then
364+
for lib_path in "${vulkan_lib_paths[@]}"; do
365+
if [ -e "${lib_path}/${driver_base}" ]; then
366+
resolved="${lib_path}/${driver_base}"
367+
break
368+
fi
369+
done
370+
fi
371+
if [ -z "$resolved" ] && command -v ldconfig >/dev/null 2>&1; then
372+
resolved=$(ldconfig -p | awk -v n="$driver_base" '$1 == n { print $NF; exit }')
373+
fi
374+
375+
if [ -z "$resolved" ] || [ ! -e "$resolved" ]; then
376+
echo "Vulkan ICD: driver '$driver_base' for $(basename "$manifest") not installed; skipping its manifest" >&2
377+
continue
378+
fi
379+
380+
# Bundle the driver + its transitive deps (libdrm, libexpat, and
381+
# libLLVM for RADV/lavapipe, ...) so the backend is self-contained
382+
# on a runtime base image without Mesa.
383+
copy_lib "$resolved"
384+
copy_elf_deps "$resolved"
385+
386+
# Copy the manifest and rewrite its library_path to a bare soname
387+
# so the loader resolves our bundled driver via LD_LIBRARY_PATH
388+
# (run.sh adds lib/ to it) instead of a host path that won't exist
389+
# on the runtime image.
390+
cp -arfL "$manifest" "$icd_dest/" 2>/dev/null || true
391+
sed -i -E 's#("library_path"[[:space:]]*:[[:space:]]*")[^"]*/#\1#' "$icd_dest/$(basename "$manifest")"
392+
done
308393
fi
309394

310395
echo "Vulkan libraries packaged successfully"
@@ -345,6 +430,8 @@ package_gpu_libs() {
345430
export -f package_gpu_libs
346431
export -f copy_lib
347432
export -f copy_libs_glob
433+
export -f is_core_lib
434+
export -f copy_elf_deps
348435
export -f package_cuda_libs
349436
export -f package_rocm_libs
350437
export -f package_intel_libs

0 commit comments

Comments
 (0)