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
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.