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