Skip to content

fix(gpu-libs): bundle cuDNN only where it is used, and complete it when it is#10946

Merged
mudler merged 1 commit into
masterfrom
fix/cudnn-complete-lib-set
Jul 19, 2026
Merged

fix(gpu-libs): bundle cuDNN only where it is used, and complete it when it is#10946
mudler merged 1 commit into
masterfrom
fix/cudnn-complete-lib-set

Conversation

@localai-bot

@localai-bot localai-bot commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Symptom. longcat-video on an ARM64 L4T worker fails with CUDNN_BACKEND_TENSOR_DESCRIPTOR cudnnFinalize failed ... CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH. The model loads; it dies the moment cuDNN is first exercised.

Cause. cuDNN 9 is a dispatcher plus seven sublibraries it dlopens by bare soname. Only the dispatcher is ever a DT_NEEDED, so ldd finds it and never the seven. The allowlist in package-gpu-libs.sh force-copied three into every CUDA backend — wrong in both directions at once: too few for a backend that uses cuDNN, too many for one that doesn't.

This is not a longcat-specific accident. A fleet audit of an 11-backend L4T worker found ten in a broken end state. The single correct row is correct by accident (BUILD_TYPE=cpu, so package_cuda_libs never ran):

backend venv bundled
cpu-speaker-recognition-development 8 0
longcat-video-development 8 4
llama-cpp ×2, whisper ×2, rfdetr-cpp, sam3-cpp, stablediffusion-ggml 0 3
vllm ×2 0 3

The nine 3-of-8 backends don't bundle libcudnn_graph, which libcudnn_cnn has a hard DT_NEEDED on, so it resolves out of the runtime image at load time — confirmed live:

libcudnn_ops.so.9   => <backend>/lib/libcudnn_ops.so.9              (bundled 9.22.0)
libcudnn_graph.so.9 => /lib/aarch64-linux-gnu/libcudnn_graph.so.9   (system  9.23.2)

Two cuDNN builds in one process, held together only by the runtime image happening to carry a compatible-enough system cuDNN.

And five of those nine never call cuDNN at all. ldd | grep -ci cudnn → 0 for llama-cpp, whisper, rfdetr-cpp, sam3-cpp, stablediffusion-ggml. ggml goes through cuBLAS. They carry three cuDNN libraries with no consumer.

Fix — bundle by what the backend actually needs

situation action backends
venv has complete pip cuDNN bundle nothing longcat-video
venv has no pip cuDNN bundle all 8 (conservative) vllm
no venv, nothing references cuDNN bundle nothing llama-cpp, whisper, rfdetr-cpp, sam3-cpp, stablediffusion-ggml
no venv, something references cuDNN bundle all 8 face-detect, voice-detect

The no-venv case needs no new machinery: Go backends stage their own .so into package/lib, which is TARGET_LIB_DIR, so sweep_transitive_deps already pulls the dispatcher when it is a genuine dependency — exactly how libcudnn_graph reached longcat. cuDNN simply comes off the force-copy list, and complete_cudnn_family fills in the seven dlopen'd sublibraries around what the sweep found. Detection is a string scan rather than ldd, so dlopen-only consumers are seen too.

Python-without-pip-cuDNN stays conservative on purpose: its consumers (torch, ctranslate2, onnxruntime) live inside the venv, where the sweep does not look. A requirements-file survey cannot substitute — vllm's requirements read as stock PyPI, but its torch resolves from the Jetson index, links the bundled cuDNN and ships no nvidia-cudnn-cu13.

Keeping bundled and pip versions in agreement instead is not viable: nothing here pins nvidia-cudnn (zero occurrences), torch is unpinned for l4t13 except longcat-video, and the fleet already runs five concurrent cuDNN versions — 9.19.0.56, 9.20.0.48, 9.22.0, 9.23.2, 9.24.0.

Size impact — net increase, and why that is correct

This does not save space overall. Measured with du -ch <backend>/lib/libcudnn*: the current 3-library partial bundle is ~55-59 MB; a complete family is that plus ~517 MB of missing sublibraries ≈ 576 MB.

backends cuDNN use today after delta
llama-cpp ×2, whisper ×2, rfdetr-cpp, sam3-cpp, stablediffusion-ggml none (ggml → cuBLAS) ~57 MB 0 −400 MB total
longcat-video via venv pip set ~60 MB 0 −60 MB
vllm ×2 needs cuDNN, no pip set ~57 MB ~576 MB +1034 MB total
face-detect, voice-detect needs cuDNN ~57 MB ~576 MB +517 MB each

Net on this fleet: roughly +570 MB. The growth is the cost of correctness, paid only where cuDNN is actually used. The backends that grow are precisely the ones that work today only by silently borrowing the missing five libraries from the runtime image — that borrowing is the bug. Meanwhile seven backends that never call cuDNN stop carrying ~57 MB of it apiece.

Unexplored follow-up: the ~517 MB is dominated by libcudnn_engines_precompiled. Whether a trimmed engines set is viable has not been investigated and is not addressed here.

Guard

verify_cudnn_bundle asserts the end state: exactly one complete cuDNN visible to whoever needs one — never both, never partial, never zero for a backend that references it. Zero is correct and common otherwise. It deliberately does not accept the build image's system cuDNN as completing a partial bundle: the build image is not the runtime image, which is the whole lesson of the 9.22.0-vs-9.23.2 finding. Version-consistency alone would have missed longcat, whose four bundled libs were all 9.24.0 and mutually consistent.

Applied per family everywhere for the same dlopen reason (TensorRT, cuBLAS, cuFFT, cuSPARSE, cuSOLVER, nvRTC), with exclusions binding inside copy_lib so they cover the transitive sweep — otherwise libnvinfer's DT_NEEDED on libcudnn re-imports a partial family through the back door.

Testing

The packaging scripts' shell tests previously ran nowhere in CI; make test-build-scripts and a build-scripts lint job now gate them. 16 assertions cover all four bundling decisions and every rejected end state, including the exact 4-of-8 and 3-of-8 fleet shapes and a dlopen-only consumer.

Verification note: the first "links cuDNN" fixtures were vacuous — --as-needed had dropped the DT_NEEDED because the stub named cuDNN on the link line without calling into it, so two scenarios passed for the wrong reason. Fixed by making the consumer call a real cuDNN symbol, verified with readelf/ldd.


🤖 Generated with Claude Code

Validation on real hardware

The diagnosis above is all hardware-observed. The fix itself was additionally validated end to end on an ARM64 Thor L4T worker by reproducing the exact end state this change produces for longcat-video (venv=8 bundled=0) — moving the partial bundled set out of <backend>/lib/ so the venv's complete pip set resolves — then re-running a real generation request.

Single variable changed:

bundled cuDNN result
before 4-of-8 @ 9.24.0 shadowing venv 9.20.0.48 CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH
after none — venv 9.20.0.48 only HTTP 200, 349,341-byte MP4, h264 512×512, 93 frames, aac audio, 3.72s

Confirmed via /proc/<pid>/maps on the generating process that all 38 cuDNN mappings resolved from venv/lib/python3.12/site-packages/nvidia/cudnn/lib/, with none from <backend>/lib/:

venv/.../nvidia/cudnn/lib/libcudnn.so.9
venv/.../nvidia/cudnn/lib/libcudnn_engines_precompiled.so.9
venv/.../nvidia/cudnn/lib/libcudnn_engines_runtime_compiled.so.9
venv/.../nvidia/cudnn/lib/libcudnn_graph.so.9

Scope of this validation, stated honestly: it confirms the runtime decision for the venv=8 bundled=0 path — the packaging script's own logic is covered by the 16 shell assertions, not by this test, and no image was rebuilt. The venv=0 bundled=8 path that nine backends depend on (vllm, face-detect, voice-detect) is unit-tested only; validating it requires a CI image build, since it means adding the five missing sublibraries rather than removing four.

…en it is

cuDNN 9 is a dispatcher (libcudnn.so.9) plus seven sublibraries the dispatcher
dlopen()s by bare soname. Only the dispatcher is ever a DT_NEEDED, so ldd finds
it and never the seven. The allowlist force-copied three of them
(libcudnn.so*, libcudnn_ops.so*, libcudnn_cnn.so*) into every CUDA backend,
which is wrong in both directions at once: too few libraries for a backend that
uses cuDNN, and too many for one that does not.

On an L4T fleet, ten of the eleven backends carrying cuDNN were in a broken end
state; the one that was correct was correct by accident, being BUILD_TYPE=cpu
so package_cuda_libs never ran for it.

  longcat-video bundled 4 of 8 at 9.24.0 over a complete pip set at 9.20.0.48
  in its venv. libbackend.sh puts lib/ on LD_LIBRARY_PATH, searched before
  DT_RUNPATH, so the bundle won and the rest still came from the venv:
  CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH.

  Nine others bundled 3 of 8 and had no venv cuDNN. None bundled
  libcudnn_graph, which libcudnn_cnn has a hard DT_NEEDED on, so it resolved
  out of the runtime image and the process ran bundled 9.22.0 against system
  9.23.2.

Five of those nine - llama-cpp, whisper, rfdetr-cpp, sam3-cpp,
stablediffusion-ggml - do not reference cuDNN at all. ggml goes through cuBLAS.
They were carrying ~57 MB of cuDNN with no consumer, and completing the family
for them would have taken that to ~576 MB for nothing.

Sizes overall: backends with no cuDNN consumer shed ~57 MB each (seven
instances on the fleet measured, plus longcat's ~60 MB), while the ones that
genuinely use cuDNN grow from ~57 MB to ~576 MB, because the five missing
sublibraries are ~517 MB, dominated by libcudnn_engines_precompiled. Net on
that fleet is an increase of roughly 570 MB. That growth is the bug being paid
off, not a regression: those backends only work today by silently borrowing the
missing five from the runtime image. Whether the engines set can be trimmed is
an open question, not addressed here.

So bundle per backend, by what that backend actually needs:

  - venv has a complete pip cuDNN -> bundle nothing; $ORIGIN resolves the pip
    set, which is the one its torch was built against            (longcat-video)
  - venv has no pip cuDNN         -> bundle the complete family. Stays
    conservative rather than detecting consumers: for a Python backend they sit
    inside the venv (torch, ctranslate2, onnxruntime) where the sweep does not
    look                                                                 (vllm)
  - no venv, nothing references cuDNN -> bundle nothing    (llama-cpp, whisper,
                                     rfdetr-cpp, sam3-cpp, stablediffusion-ggml)
  - no venv, something references it   -> bundle the complete family
                                                  (face-detect, voice-detect)

The no-venv case needs no new machinery. Go backends stage their own shared
object into package/lib, which IS the target dir, so sweep_transitive_deps
already pulls the dispatcher when it is a genuine dependency - that is exactly
how libcudnn_graph reached longcat. cuDNN simply comes off the force-copy list,
and complete_cudnn_family fills in the seven dlopen'd sublibraries around
whatever the sweep found. Detection is a string scan rather than ldd, so a
consumer that only dlopen()s cuDNN is seen too; over-matching costs an unused
library, under-matching costs a backend that cannot load.

Keeping bundled and pip versions in agreement instead is not viable: nothing
here pins nvidia-cudnn (zero occurrences), torch is unpinned for l4t13 except
longcat-video, and the fleet already runs five concurrent cuDNN versions -
9.19.0.56, 9.20.0.48, 9.22.0, 9.23.2, 9.24.0.

verify_cudnn_bundle asserts the end state: exactly one complete cuDNN visible to
whoever needs one - never both, never partial, and never zero for a backend that
references it. Zero is correct and common otherwise. It deliberately does not
accept the build image's system cuDNN as completing a partial bundle, which is
the shape that had been shipping silently; the build image is not the runtime
image. A version check alone would have missed longcat too, whose four bundled
libs were all 9.24.0 and mutually consistent.

Match per family for the other components for the same dlopen reason: TensorRT
(libnvinfer_plugin, libnvinfer_builder_resource), cuBLAS, cuFFT, cuSPARSE,
cuSOLVER, nvRTC. Exclusions bind inside copy_lib so they cover the sweep.

The packaging scripts' shell tests ran nowhere in CI. Add make
test-build-scripts and a lint workflow job so they gate every PR.

Fixes #10905

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 golangci-lint shellcheck
@mudler
mudler enabled auto-merge (squash) July 19, 2026 07:41
@mudler
mudler merged commit 963c637 into master Jul 19, 2026
67 checks passed
@mudler
mudler deleted the fix/cudnn-complete-lib-set branch July 19, 2026 07:48
mudler added a commit that referenced this pull request Jul 20, 2026
The backend matrix path filter only matched files under a backend's own
directory, so a change to shared build infrastructure rebuilt nothing at
all: an empty matrix, every job green, and the change reaching no image.

PR #10946 fixed scripts/build/package-gpu-libs.sh shipping a partial
4-of-8 cuDNN library set, which mixed versions with the venv's pip cuDNN
and produced CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH at inference time.
It merged 1h48m after the weekly full-matrix cron had already run, so no
backend image ever received the fix and nothing signalled that it had
been un-shipped.

Add a SHARED_BUILD_INPUTS table mapping each shared path to the narrowest
set of matrix entries it can honestly invalidate, plus a generic rule for
backend/Dockerfile.<x> (which each entry already names). A full matrix is
417 Linux + 56 Darwin builds, so package-gpu-libs.sh now rebuilds the 176
Python entries rather than everything. Unclassified files under
scripts/build/ fall back to a full rebuild deliberately: over-building is
recoverable, silently shipping nothing is not.

Extract the filtering logic to scripts/lib/backend-filter.mjs so it can be
unit-tested without bun, js-yaml or a GitHub API round-trip, and run those
tests from the existing lint workflow via `make test-ci-scripts`.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
mudler added a commit that referenced this pull request Jul 20, 2026
… coverage-ratchet fixes (#10989)

* fix(http): make /readyz reflect startup readiness instead of always 200

/readyz was registered as a static handler returning 200 unconditionally,
so it carried no information: it was green whenever it could be reached at
all. Readiness could not distinguish "serving" from "still starting", and
any future change that started the HTTP listener earlier would silently
turn the probe into a lie.

Track startup completion on the Application (atomic flag, flipped at the
very end of New() on the success path only) and have the readiness handler
consult it per request, returning 503 with a small JSON body while startup
is in progress. A nil readiness source fails open so embedders keep the
historical behaviour.

/healthz is deliberately left readiness-independent. Liveness and readiness
answer different questions, and failing liveness during a long preload makes
an orchestrator restart the pod mid-download so the preload never finishes.

This matters because since #10949 the startup preload materializes
HuggingFace artifacts for managed backends: tens of GB for a large model
(31 GB observed on a live cluster). Both probes stay in quietPaths and stay
exempt from auth.

Note the listener is still started only after New() returns, so today the
not-ready state is not observable over HTTP. Moving the listener earlier is
a separate, deliberate decision and is not made here.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(gitignore): anchor the mock-backend pattern so its source dir is traversable

The bare `mock-backend` pattern matched the *directory*
tests/e2e/mock-backend/, not just the binary built into it. Git will not
descend into an ignored directory even for tracked files, so
`git add tests/e2e/mock-backend/main.go` required -f. This was hit while
working on #10970.

Anchor it to the artifact's full path. The built binary stays ignored (it is
also covered by tests/e2e/mock-backend/.gitignore) while the source directory
becomes traversable again.

Verified with `git check-ignore -v`: a new source file under
tests/e2e/mock-backend/ is no longer ignored, and the binary produced by
`make build-mock-backend` still is.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(coverage): raise the coverage ratchet from 48.5% to 54.2%

The committed baseline had drifted well below reality: it still read 48.5%
while a full instrumented run measures 54.2%. A stale-low baseline makes the
gate meaningless — coverage could regress by more than 5 percentage points
and still pass.

Raising a ratchet is a deliberate act, not something to fold into an
unrelated fix, so it gets its own commit. The headroom was earned by tests
landed in #10946, #10947, #10948, #10949, #10956, #10967, #10968, #10970 and
#10975.

Measured with `make test-coverage` on this branch (the same instrumented run
`make test-coverage-baseline` uses: ginkgo over ./pkg and ./core plus the
in-process tests/e2e suite, --covermode=atomic, --coverpkg over core/... and
pkg/..., generated protobuf excluded). The run completed with exit 0 and zero
spec failures; the total was then written with the exact command the
test-coverage-baseline target uses:

  go tool cover -func=coverage/coverage.out \
    | awk '/^total:/{gsub(/%/,"",$NF); print $NF}' > coverage-baseline.txt

Verified afterwards with scripts/coverage-check.sh, which reports OK.

Note the measured figure includes the readiness specs added earlier on this
branch, so it is a demonstrated floor rather than an estimate.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants