Skip to content

shim: make the GPU adapter loadable into its targets, and ship it (#121) - #134

Merged
dpsoft merged 6 commits into
mainfrom
feat/121-portable-shim
Sep 8, 2026
Merged

shim: make the GPU adapter loadable into its targets, and ship it (#121)#134
dpsoft merged 6 commits into
mainfrom
feat/121-portable-shim

Conversation

@dpsoft

@dpsoft dpsoft commented Sep 8, 2026

Copy link
Copy Markdown
Owner

GPU mode has never been installable by anyone but its author. No release ships the shim, and the
shim we build could not load into the processes it is injected into.

before after
loads on ubuntu 22.04 / 24.04 / 25.04 fails all three loads on all three
glibc floor 2.42 (GLIBC_ABI_GNU2_TLS) 2.34
NEEDED libcupti yes — pinned to CUDA 13 gone, resolved at runtime
RUNPATH the build machine's CUDA path none
CI builds it impossible builds, gates, uploads an artifact
still profiles 8,000/8,000 executions, all exact, all matched

Why it could not be fixed before

DT_NEEDED libcupti.so.13 meant building the adapter required libcupti to link against. The
runners have no CUDA toolkit, so CI could not compile the shim at all — nothing gated it and
no release carried it. "No release ships it" was a consequence, not neglect.

All twenty CUPTI entry points are FUNCTIONS — zero data symbols — so resolving them with
dlopen/dlsym needs nothing from the linker. Building now needs only the headers, which is
what makes the CI job possible. One artifact spans CUDA 12 and 13 and picks up the pip-installed
CUPTI that PyTorch already pulls in, frequently the only one present.

The portability fix

Built in ubuntu:22.04, because the floor is set by the oldest symbol the linker will reference
and no flag lowers it afterwards: __isoc23_*@GLIBC_2.38 comes from g++ predefining _GNU_SOURCE
unconditionally, reproduced across every C++ std mode. -mtls-dialect=gnu removes
GLIBC_ABI_GNU2_TLS, which is a marker rather than a version — satisfied only by glibc 2.42
however low the numeric requirements are, and the actual cause of the load failures. It is not a
GCC default; it is a distro configure choice Fedora makes.

shim/elfgate.sh keeps it true: all three version namespaces (a .so can clear a
GLIBC_2.28 bar and still need GLIBCXX_3.4.31), the GNU2_TLS marker, no baked RUNPATH, exactly
one exported symbol, no leaked std:: symbols preemptible against the host's C++ runtime, and the
USDT notes the consumer attaches to. Run against the shim we ship today it fails four of them.

It declines instead of crashing

This runs inside a process that never asked to be profiled. On a container with no CUDA the
library now loads — previously impossible — and InitializeInjection reports which sonames it
tried, that CUPTI ships with the toolkit rather than the driver and is not injected by
nvidia-container-toolkit, and that the process runs unprofiled. Then returns.

Three things worth reviewing closely

The guard nearly got disabled. My first version redefined cuptiX as macros pointing at the
resolved table — colliding with the poison macros in cupti_guard.h that make a direct CUPTI call
outside the wrapper a compile error. That enforcement is why #99 cannot recur (two of our threads
inside CUPTI deadlocked the profiled application permanently). The wrappers call dyn::table()
directly instead; check-cupti-guard still reports the guarded call compiling and all five
unguarded ones not.

An undocumented minimum CUPTI version. The adapter uses CUpti_ActivityKernel12, absent from
CUDA 13.0's CUPTI — the build fails there. Nothing stated this anywhere; the CI job is now the only
place it is written down.

dlerror() clears on read, so dlerror() ? dlerror() : "…" printed (null) for every real
failure. Found by running the missing-CUPTI path in a container rather than reading the code.

The CI job asserts rather than assumes

It installs CUPTI headers only (proven in a clean container first), builds and gates on the
runner, builds the portable artifact in ubuntu:22.04 and gates it at the real floor, and then
dlopens it on 22.04, 24.04 and 25.04 rather than inferring loadability from the ELF — the
version headers said the old shim was fine too; only dlopen said otherwise.

Still open on #121

A published shim image for the init-container pattern (the artifact exists now, the image does
not), and the positive injection self-check — CUDA_INJECTION64_PATH still fails open, so a broken
install is indistinguishable from a workload that launched no kernels.

The shim is dlopened by the CUDA driver INTO someone else's process, so its
requirements are requirements on the TARGET. The shipped one required glibc
2.42 and failed to dlopen on Ubuntu 22.04, 24.04 and 25.04 -- essentially every
PyTorch image -- with

  version `GLIBC_ABI_GNU2_TLS' not found

Nothing caught it because it loaded perfectly on the machine that built it, and
no release ships it anyway, so GPU mode has never been installable by anyone
but its author.

`make -C shim nvidia-portable' builds in ubuntu:22.04, because the floor is set
by the oldest symbol the linker will reference and no flag lowers it after the
fact: __isoc23_*@GLIBC_2.38 comes from g++ predefining _GNU_SOURCE
unconditionally, reproduced across every C++ std mode, so the baseline has to
BE old. -mtls-dialect=gnu removes GLIBC_ABI_GNU2_TLS, which is a marker rather
than a version and was the actual cause; it is not a GCC default but a distro
configure choice Fedora makes. -static-libstdc++ makes the shim immune to the
target's C++ runtime, which matters because conda and PyTorch ship their own,
older one.

  shipped   glibc 2.38 + GNU2_TLS, GLIBCXX_3.4.29, RUNPATH to this machine
  portable  glibc 2.34, no libstdc++, no RUNPATH, soname set

  ubuntu:22.04  shipped FAILS   portable LOADS
  ubuntu:24.04  shipped FAILS   portable LOADS
  ubuntu:25.04  shipped FAILS   portable LOADS

elfgate.sh is the part that keeps it true. It checks all THREE version
namespaces rather than glibc alone -- a .so can clear a GLIBC_2.28 bar and
still require GLIBCXX_3.4.31 -- plus the absence of GLIBC_ABI_GNU2_TLS, no
baked RUNPATH pointing at the build machine's CUDA, exactly one exported
symbol, no leaked std:: symbols preemptible against the host's C++ runtime, and
the presence of the USDT notes the consumer attaches to.

Run against the shim we ship today it fails on four of those, which is the
point: this could have been caught at any time in the last year by looking.
DT_NEEDED libcupti.so.13 pinned the adapter to one CUDA major version and made
it unloadable wherever that version is absent -- which is the normal case,
because CUPTI ships with the CUDA TOOLKIT, not the driver, and
nvidia-container-toolkit never injects it (zero cupti hits in nvc_info.c).

It also made the shim unbuildable in CI, and that is the reason this is being
done now rather than as a nicety: the runners have no CUDA toolkit, so nothing
could compile the shim, so nothing could gate it or publish it, so GPU mode had
no release artifact at all. Resolving at runtime needs only the HEADERS to
build.

  before  NEEDED libcupti.so.13, libstdc++, libm, libgcc_s, libc
          RUNPATH /usr/local/cuda-13.3/targets/x86_64-linux/lib
  after   NEEDED libstdc++, libm, libgcc_s, libc

Safe because all twenty entry points are FUNCTIONS -- zero data symbols -- so
nothing needs the linker's participation. One artifact now spans CUDA 12 and
13 and picks up the pip-installed CUPTI that PyTorch already pulls in, which is
frequently the only one present.

Verified end to end, not just linked: the C++ CUDA workload profiles to 8000
executions all exact and all matched with both the local and the
ubuntu:22.04-built portable shim.

DECLINES rather than crashes. This runs inside a process that never asked to be
profiled; killing it because the operator has no toolkit installed would be an
unacceptable way to report that. On a container with no CUDA at all the library
now LOADS (it could not before) and InitializeInjection says which sonames it
tried, that CUPTI comes from the toolkit rather than the driver, and that the
process runs unprofiled -- then returns.

The macros this first used to redirect cuptiX collided with the poison macros
in cupti_guard.h, which turn a direct CUPTI call outside the wrapper into a
compile error. That enforcement is why issue #99 cannot recur -- two of our
threads inside CUPTI at once deadlocked the profiled application permanently --
and shadowing those names would have silently disabled it. The wrappers call
dyn::table() directly instead and the poison stands: check-cupti-guard still
reports the guarded call compiling and all five unguarded ones not.

dlerror() is read once into a variable, because it clears on read and the
`dlerror() ? dlerror() : "..."` in the first version printed (null) for every
real failure.
CI had no shim step at all, and could not have had one: building the adapter
needed libcupti to link against, the runners have no CUDA toolkit, so nothing
compiled the shim, nothing gated it, and no release carried it. GPU mode has
never had a downloadable artifact. Resolving CUPTI with dlopen removed the
link-time dependency and made this job possible.

Four steps, each asserting something that was previously assumed:

  1. Install CUPTI HEADERS ONLY -- no runtime, no toolkit, no GPU. Proven in a
     clean ubuntu:24.04 container before writing this. cuda-crt and
     cuda-cudart-dev come along because CUPTI's headers include
     crt/host_defines.h, which the CUPTI package does not itself carry.

     13-3 rather than 13-0, because the adapter uses CUpti_ActivityKernel12 and
     CUDA 13.0's CUPTI does not define it. That minimum was real and undocumented;
     this is now the only place it is written down.

  2. Build and gate on the runner. Its glibc is 2.39, so this checks everything
     elfgate knows except the floor.

  3. Build the PORTABLE artifact in ubuntu:22.04 and gate it at 2.34 -- the
     floor is enforced where the artifact that must meet it is produced.

  4. Prove it LOADS on ubuntu 22.04, 24.04 and 25.04 by dlopening it there,
     rather than inferring loadability from the ELF. The version headers said
     the old shim was fine too; only dlopen said otherwise.

The portable shim is uploaded as an artifact, so there is finally something to
put in an init container.
The first CI run failed on the local build for requiring GLIBCXX_3.4.29 --
correctly, because the gate was checking a portability property that build
never claimed. The ordinary target links libstdc++ dynamically on purpose; only
the artifact a release ships must not.

elfgate.sh takes 'any' to permit it, and the CI step uses that for the runner
build while the portable artifact is still gated strictly at glibc 2.34 with no
libstdc++ at all. A gate that fails a build for a property that build never
promised is a gate people learn to switch off.
…ne (#121)

GLIBC_ABI_GNU2_TLS is a marker satisfied only by glibc 2.42 however low the
numeric requirements are, and the flag that removes it is not a GCC default --
it is a distro configure choice. Fedora makes the opposite one.

That divergence is the whole story of this issue: a shim built on a Fedora
workstation carried the marker and could not load into any mainstream PyTorch
image, while the same source on an Ubuntu runner did not carry it and passed
every check. Applying the flag everywhere means a developer's build now differs
from the shipped artifact in nothing that affects whether it loads.

Makefile becomes a prerequisite of the .so at the same time, because it is not:
changing CXXFLAGS left a stale library that looked rebuilt, and the gate duly
reported the marker still present after the flag removing it had been added.
A build that ignores its own flags is a worse problem than the flag it ignored.
The build and both gates passed; the load proof failed with 'gcc: not found'.
Mounting the host's /tmp over the container's made it read-only, so apt-get
could not unpack anything, and the install failure was invisible because its
output went to /dev/null.

tryload.c now goes in the workspace, which is already mounted, and the
container writes only to its own /tmp. The apt output is no longer discarded
either -- a step that hides why a package did not install spends a CI round
telling you the consequence instead of the cause.
@dpsoft
dpsoft merged commit 6440198 into main Sep 8, 2026
11 checks passed
@dpsoft
dpsoft deleted the feat/121-portable-shim branch September 8, 2026 14:25
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.

1 participant