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
11 changes: 11 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,14 @@ jobs:
touch core/http/react-ui/dist/index.html
- name: lint
run: make lint

build-scripts:
# The image packaging scripts encode invariants that only surface inside a
# container build (a missing transitive dep, a partial cuDNN family). Their
# shell tests need nothing but bash + gcc + ldd, so run them on every PR
# rather than waiting on a multi-GB cross-arch backend image build.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: run packaging script tests
run: make test-build-scripts
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ COVERAGE_E2E_LABELS?=!real-models
COVERAGE_EXCLUDE_RE?=grpc/proto/.*[.]pb[.]go


.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-ui test-ui-coverage-baseline test-ui-coverage-check install-hooks build vendor lint lint-all
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-build-scripts test-ui test-ui-coverage-baseline test-ui-coverage-check install-hooks build vendor lint lint-all

all: help

Expand Down Expand Up @@ -208,6 +208,13 @@ test: prepare-test
test-backend-cpp:
bash backend/cpp/run-unit-tests.sh

## Runs the shell-level regression tests for the image packaging scripts
## (scripts/build/*_test.sh). These guard invariants that only ever break
## inside a container build - a missing transitive dep, a partial cuDNN
## family - and that no Go test can observe. Needs only bash + gcc + ldd.
test-build-scripts:
@set -e; for t in scripts/build/*_test.sh; do echo "== $$t"; bash "$$t"; done

## Runs the core suite ($(TEST_PATHS)) with statement-coverage instrumentation
## and writes a merged profile to $(COVERAGE_PROFILE). Deliberately omits
## --fail-fast so a single failure doesn't truncate the coverage number, and
Expand Down
6 changes: 5 additions & 1 deletion backend/Dockerfile.python
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ ARG DEPS_REFRESH=initial

RUN cd /${BACKEND} && PORTABLE_PYTHON=true make

# Package GPU libraries into the backend's lib directory
# Package GPU libraries into the backend's lib directory.
#
# Must stay after the venv is built above: package-gpu-libs.sh inspects
# /${BACKEND}/venv to decide whether this backend already carries a complete
# cuDNN from pip, and bundles one only when it does not (issue #10905).
RUN mkdir -p /${BACKEND}/lib && \
TARGET_LIB_DIR="/${BACKEND}/lib" BUILD_TYPE="${BUILD_TYPE}" CUDA_MAJOR_VERSION="${CUDA_MAJOR_VERSION}" \
bash /package-gpu-libs.sh "/${BACKEND}/lib"
Expand Down
308 changes: 308 additions & 0 deletions scripts/build/package-gpu-libs-cudnn_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
#!/bin/bash
# Regression test for the cuDNN packaging in scripts/build/package-gpu-libs.sh.
#
# cuDNN 9 is a dispatcher (libcudnn.so.9) plus seven sublibraries the dispatcher
# dlopen()s by bare soname at runtime. Only the dispatcher is ever a DT_NEEDED,
# so ldd finds it but never the seven - they have to be completed explicitly.
#
# Three end states are correct, and which one applies is a property of the
# backend, not of the Dockerfile that built it:
#
# venv has a complete pip cuDNN -> bundle nothing (longcat-video)
# venv has no pip cuDNN -> bundle all 8 (vllm: Jetson-index torch
# links cuDNN, no wheel)
# no venv, nothing links cuDNN -> bundle nothing (llama-cpp, whisper,
# rfdetr-cpp, sam3-cpp,
# stablediffusion-ggml)
# no venv, something links cuDNN -> bundle all 8 (face-detect, voice-detect)
#
# Everything else is a bug this file exists to catch. Historically the allowlist
# force-copied three cuDNN libs into every CUDA backend, which produced the two
# failures behind issue #10905: a partial bundle shadowing a complete pip set
# via LD_LIBRARY_PATH (longcat), and a partial bundle silently completed from
# the runtime image's system cuDNN (vllm and five Go/C++ backends).
#
# Requires gcc (present in the build images); skips otherwise.
set -euo pipefail

CURDIR=$(dirname "$(realpath "$0")")
SCRIPT="$CURDIR/package-gpu-libs.sh"

if ! command -v gcc >/dev/null 2>&1; then
echo "SKIP: gcc not available"
exit 0
fi

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

# The full cuDNN 9 family as shipped by the libcudnn9-cuda-13 apt package.
CUDNN_FAMILY=(
libcudnn
libcudnn_adv
libcudnn_cnn
libcudnn_engines_precompiled
libcudnn_engines_runtime_compiled
libcudnn_graph
libcudnn_heuristic
libcudnn_ops
)

echo 'int cudnn_stub(void){return 0;}' > "$WORK/stub.c"

# A consumer must actually CALL into cuDNN, not merely name it on the link line:
# the toolchain defaults to --as-needed and drops the DT_NEEDED otherwise, which
# would leave the fixture silently testing nothing.
printf 'int cudnn_stub(void);\nint consume(void){return cudnn_stub();}\n' > "$WORK/consumer.c"

# A system (apt) cuDNN: real .so.9.24.0 files behind .so.9 symlinks. This is
# what the L4T build image carries and it holds no TensorRT, matching reality -
# nothing in the Dockerfiles installs libnvinfer.
SYS="$WORK/sys"
mkdir -p "$SYS"
for name in "${CUDNN_FAMILY[@]}"; do
gcc -shared -fPIC -o "$SYS/${name}.so.9.24.0" "$WORK/stub.c"
ln -s "${name}.so.9.24.0" "$SYS/${name}.so.9"
done

# Same, plus a TensorRT stand-in that DT_NEEDEDs cuDNN. Used to prove a bundled
# library pulling cuDNN in also gets the family completed, and that excluding
# cuDNN never over-excludes its dependents.
SYSTRT="$WORK/systrt"
mkdir -p "$SYSTRT"
cp -a "$SYS"/. "$SYSTRT/"
gcc -shared -fPIC -o "$SYSTRT/libnvinfer.so.10" "$WORK/consumer.c" \
-L"$SYS" -l:libcudnn.so.9 -Wl,-rpath,"$SYS"

# Build a backend dir: <edir>/lib is the bundle target, <edir>/venv is the venv.
# pip ships cuDNN as plain libcudnn*.so.9 files with no versioned real name.
#
# "links-cudnn" reproduces the Go/C++ layout: package.sh stages the backend's
# own shared object into package/lib, which IS the target dir, so the existing
# transitive sweep sees it. That is how face-detect/voice-detect are detected.
#
# $1 = backend name, $2 = pip-cudnn | venv-no-cudnn | no-venv | links-cudnn
make_backend() {
local edir="$WORK/$1"
mkdir -p "$edir/lib"
case "$2" in
pip-cudnn)
local sp="$edir/venv/lib/python3.12/site-packages/nvidia/cudnn/lib"
mkdir -p "$sp"
local name
for name in "${CUDNN_FAMILY[@]}"; do
gcc -shared -fPIC -o "$sp/${name}.so.9" "$WORK/stub.c"
done
;;
venv-no-cudnn)
mkdir -p "$edir/venv/lib/python3.12/site-packages/nvidia"
;;
links-cudnn)
gcc -shared -fPIC -o "$edir/lib/libfacedetect.so" "$WORK/consumer.c" \
-L"$SYS" -l:libcudnn.so.9 -Wl,-rpath,"$SYS"
;;
no-venv) ;;
esac
echo "$edir"
}

# Run the packager for one backend in a fresh bash. A ( ) subshell would inherit
# the COPIED_FILES dedup map from a previous run and skip everything.
# $1 = backend lib dir, $2 = system lib dir
run_packager() {
env BUILD_TYPE=l4t CUDA_LIB_DIRS="$2" \
bash -c 'source "$1" "$2"; package_cuda_libs' _ "$SCRIPT" "$1" 2>&1
}

bundled_cudnn() {
find "$1" -maxdepth 1 -name 'libcudnn*' -printf '%f\n' 2>/dev/null | sort | tr '\n' ' '
}

# Report how many of the 8 sonames are present, for the "expect all" cases.
missing_cudnn() {
local dir="$1" name out=()
for name in "${CUDNN_FAMILY[@]}"; do
[ -e "$dir/${name}.so.9" ] || out+=("${name}.so.9")
done
echo "${out[*]:-}"
}

rc=0
pass() { echo "PASS: $1"; }
fail() { echo "FAIL: $1"; rc=1; }

# --- 1. venv provides a complete pip cuDNN (longcat-video).
EDIR=$(make_backend pipbackend pip-cudnn)
run_packager "$EDIR/lib" "$SYSTRT" >/dev/null 2>&1 || true
leaked=$(bundled_cudnn "$EDIR/lib")
if [ -z "$leaked" ]; then
pass "venv with complete pip cuDNN -> nothing bundled"
else
fail "venv already has cuDNN but we bundled: $leaked"
fi
if [ -e "$EDIR/lib/libnvinfer.so.10" ]; then
pass "excluding cuDNN does not over-exclude its dependents"
else
fail "excluding cuDNN dropped libnvinfer.so.10 too"
fi

# --- 2. venv exists but ships NO pip cuDNN (vllm). The consumers live inside
# the venv where the sweep cannot see them, so this stays conservative.
EDIR=$(make_backend vllmbackend venv-no-cudnn)
run_packager "$EDIR/lib" "$SYS" >/dev/null 2>&1 || true
missing=$(missing_cudnn "$EDIR/lib")
if [ -z "$missing" ]; then
pass "venv without pip cuDNN -> complete family bundled"
else
fail "venv without pip cuDNN left the backend short of cuDNN: $missing"
fi

# --- 3. no venv and nothing links cuDNN (llama-cpp, whisper, rfdetr-cpp,
# sam3-cpp, stablediffusion-ggml). ggml uses cuBLAS, not cuDNN. These carry
# ~57 MB of partial cuDNN today; completing the family for them would take that
# to ~576 MB, all of it for libraries with no consumer.
EDIR=$(make_backend gonocudnn no-venv)
run_packager "$EDIR/lib" "$SYS" >/dev/null 2>&1 || true
leaked=$(bundled_cudnn "$EDIR/lib")
if [ -z "$leaked" ]; then
pass "no venv and nothing links cuDNN -> nothing bundled"
else
fail "bundled cuDNN into a backend with no cuDNN consumer: $leaked"
fi

# --- 4. no venv but the backend's own .so links cuDNN (face-detect,
# voice-detect, built with -DFACEDETECT_GGML_CUDNN=ON on arm64 + CUDA 13).
EDIR=$(make_backend golinkscudnn links-cudnn)
run_packager "$EDIR/lib" "$SYS" >/dev/null 2>&1 || true
missing=$(missing_cudnn "$EDIR/lib")
if [ -z "$missing" ]; then
pass "no venv but the backend links cuDNN -> complete family bundled"
else
fail "backend links cuDNN but the family was left incomplete: $missing"
fi

# --- 5. a bundled library pulling cuDNN in must also get the family completed.
# Nothing installs TensorRT today, but if it ever is, libnvinfer's DT_NEEDED on
# libcudnn would otherwise reintroduce exactly the partial set from #10905.
EDIR=$(make_backend gotrt no-venv)
run_packager "$EDIR/lib" "$SYSTRT" >/dev/null 2>&1 || true
missing=$(missing_cudnn "$EDIR/lib")
if [ -z "$missing" ]; then
pass "a dependent dragging cuDNN in -> complete family bundled"
else
fail "libnvinfer pulled cuDNN in but the family was left incomplete: $missing"
fi

# --- 6. no cuDNN in the build image at all (every non-arm64 CUDA image).
EDIR=$(make_backend nocudnnanywhere venv-no-cudnn)
NOSYS="$WORK/nosys"
mkdir -p "$NOSYS"
if run_packager "$EDIR/lib" "$NOSYS" >/dev/null 2>&1; then
pass "no cuDNN in the build image and none needed -> build still succeeds"
else
fail "build failed for a backend that has no cuDNN available anywhere"
fi

# --- Guard unit checks. Source once for direct access to the helpers.
mkdir -p "$WORK/guard"
# shellcheck source=/dev/null
source "$SCRIPT" "$WORK/guard"

for fn in verify_cudnn_bundle cudnn_family_state cudnn_venv_lib_dir cudnn_is_referenced; do
if ! declare -F "$fn" >/dev/null; then
echo "FAIL: package-gpu-libs.sh does not define $fn"
exit 1
fi
done

# The shape five Go/C++ backends plus vllm ship today: three of eight bundled,
# no venv cuDNN, a complete cuDNN in the build image. It survives only because
# the runtime image's system cuDNN completes the family - libcudnn_cnn.so.9 has
# a hard DT_NEEDED on libcudnn_graph.so.9, which none of them bundle, so it
# resolves to /lib/aarch64-linux-gnu and the process runs bundled 9.22.0 against
# system 9.23.2. The build image is not the runtime image; this must not pass.
FLEET="$WORK/fleetshape"
mkdir -p "$FLEET"
for name in libcudnn libcudnn_cnn libcudnn_ops; do
cp "$SYS/${name}.so.9.24.0" "$FLEET/${name}.so.9.22.0"
ln -s "${name}.so.9.22.0" "$FLEET/${name}.so.9"
done
if verify_cudnn_bundle "$FLEET" absent complete 2>/dev/null; then
fail "verify_cudnn_bundle accepted the venv=0 bundled=3 fleet shape"
else
pass "verify_cudnn_bundle rejected the venv=0 bundled=3 fleet shape"
fi

# Mixed-version bundle: two cuDNN builds in one directory.
MIXED="$WORK/mixed"
mkdir -p "$MIXED"
for name in "${CUDNN_FAMILY[@]}"; do
cp "$SYS/${name}.so.9.24.0" "$MIXED/"
ln -sf "${name}.so.9.24.0" "$MIXED/${name}.so.9"
done
cp "$SYS/libcudnn_adv.so.9.24.0" "$MIXED/libcudnn_adv.so.9.20.0"
ln -sf libcudnn_adv.so.9.20.0 "$MIXED/libcudnn_adv.so.9"
if verify_cudnn_bundle "$MIXED" absent absent 2>/dev/null; then
fail "verify_cudnn_bundle accepted a mixed 9.24.0 / 9.20.0 bundle"
else
pass "verify_cudnn_bundle rejected a mixed-version bundle"
fi

# cuDNN in BOTH the bundle and the venv: the bundle shadows the venv, so even
# two individually complete sets are a misconfiguration.
COMPLETE="$WORK/complete"
mkdir -p "$COMPLETE"
for name in "${CUDNN_FAMILY[@]}"; do
cp "$SYS/${name}.so.9.24.0" "$COMPLETE/"
ln -sf "${name}.so.9.24.0" "$COMPLETE/${name}.so.9"
done
if verify_cudnn_bundle "$COMPLETE" complete absent 2>/dev/null; then
fail "verify_cudnn_bundle accepted cuDNN in both the bundle and the venv"
else
pass "verify_cudnn_bundle rejected cuDNN in both the bundle and the venv"
fi

# Zero cuDNN is CORRECT when nothing references it - that is llama-cpp, whisper
# and friends, and it is the common case. The guard must not demand a cuDNN for
# backends that never call one just because the build image happens to have it.
EMPTY="$WORK/empty"
mkdir -p "$EMPTY"
if verify_cudnn_bundle "$EMPTY" absent complete; then
pass "zero cuDNN accepted when nothing references it"
else
fail "verify_cudnn_bundle demanded a cuDNN no consumer asked for"
fi

# Zero cuDNN is WRONG when something does reference it. A backend whose own .so
# links the dispatcher and that ends up with no cuDNN cannot load at all.
NEEDS="$WORK/needscudnn"
mkdir -p "$NEEDS"
gcc -shared -fPIC -o "$NEEDS/libfacedetect.so" "$WORK/consumer.c" \
-L"$SYS" -l:libcudnn.so.9 -Wl,-rpath,"$SYS"
if verify_cudnn_bundle "$NEEDS" absent complete 2>/dev/null; then
fail "verify_cudnn_bundle accepted zero cuDNN for a backend that links it"
else
pass "verify_cudnn_bundle rejected zero cuDNN for a backend that links it"
fi

# cudnn_is_referenced must see a dlopen()ed soname too, not just DT_NEEDED.
# A consumer that only ever dlopen()s cuDNN has the string in .rodata and no
# dynamic entry at all, so an ldd-based check would miss it entirely.
DLOPEN="$WORK/dlopenonly"
mkdir -p "$DLOPEN"
printf 'const char *n="libcudnn.so.9";\nint f(void){return 0;}\n' > "$WORK/dl.c"
gcc -shared -fPIC -o "$DLOPEN/libdlopener.so" "$WORK/dl.c"
if cudnn_is_referenced "$DLOPEN"; then
pass "cudnn_is_referenced detects a dlopen-only consumer"
else
fail "cudnn_is_referenced missed a dlopen-only consumer (ldd cannot see it)"
fi

# The venv-only end state stays valid.
if verify_cudnn_bundle "$EMPTY" complete complete; then
pass "verify_cudnn_bundle accepts the venv-only end state"
else
fail "verify_cudnn_bundle rejected the correct venv-only end state"
fi

exit $rc
Loading
Loading