fix(gpu-libs): bundle cuDNN only where it is used, and complete it when it is#10946
Merged
Conversation
…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
approved these changes
Jul 19, 2026
mudler
enabled auto-merge (squash)
July 19, 2026 07:41
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>
This was referenced Jul 20, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom.
longcat-videoon an ARM64 L4T worker fails withCUDNN_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 aDT_NEEDED, solddfinds it and never the seven. The allowlist inpackage-gpu-libs.shforce-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, sopackage_cuda_libsnever ran):The nine 3-of-8 backends don't bundle
libcudnn_graph, whichlibcudnn_cnnhas a hardDT_NEEDEDon, so it resolves out of the runtime image at load time — confirmed live: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 forllama-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
The no-venv case needs no new machinery: Go backends stage their own
.sointopackage/lib, which isTARGET_LIB_DIR, sosweep_transitive_depsalready pulls the dispatcher when it is a genuine dependency — exactly howlibcudnn_graphreached longcat. cuDNN simply comes off the force-copy list, andcomplete_cudnn_familyfills in the sevendlopen'd sublibraries around what the sweep found. Detection is a string scan rather thanldd, sodlopen-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 nonvidia-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.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_bundleasserts 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
dlopenreason (TensorRT, cuBLAS, cuFFT, cuSPARSE, cuSOLVER, nvRTC), with exclusions binding insidecopy_libso they cover the transitive sweep — otherwiselibnvinfer'sDT_NEEDEDonlibcudnnre-imports a partial family through the back door.Testing
The packaging scripts' shell tests previously ran nowhere in CI;
make test-build-scriptsand abuild-scriptslint 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 adlopen-only consumer.Verification note: the first "links cuDNN" fixtures were vacuous —
--as-neededhad dropped theDT_NEEDEDbecause 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 withreadelf/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:
CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCHConfirmed via
/proc/<pid>/mapson the generating process that all 38 cuDNN mappings resolved fromvenv/lib/python3.12/site-packages/nvidia/cudnn/lib/, with none from<backend>/lib/:Scope of this validation, stated honestly: it confirms the runtime decision for the
venv=8 bundled=0path — the packaging script's own logic is covered by the 16 shell assertions, not by this test, and no image was rebuilt. Thevenv=0 bundled=8path 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.