feat: CDI-based NVIDIA GPU passthrough into gVisor actor containers - #502
feat: CDI-based NVIDIA GPU passthrough into gVisor actor containers#502eliranw wants to merge 4 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
b510ec9 to
2693528
Compare
|
eliranw is there a way to do this without introducing all the new vendored components? (21k lines of code is too much!) |
|
It seems like we decided to commit vendor packages in this repo. I think it’s best to use the cdi package instead of reimplementing it. As far as I understand, the main downside of committing the vendor folder (instead of gitignoring it) is having a large diff. Do you have a different solution? To make it easier to review, I moved the vendor changes into a separate commit. I noticed many other PRs do the same thing - does this approach make sense to you? |
2693528 to
9cf40a5
Compare
|
eliranw it's not whether the vendor-ed stuff is a separate commit or not. just that we should avoid adding stuff if not really required. see for example - main...dims:substrate:gpu-nocdi |
|
thanks for working on this I'd also like to keep imported deps minimal. I started looking at uVM without privileged and we may have a bigger problem there ... ideally I don't want to require a plugin on the host but we need permission to talk to KVM. I may have an answer to that wrinkle but broadly speaking I'm also hoping that even if not implemented yet we can figure out how we'll extend this to uVM later, as I'm also working on closing the existing gap (durable directory support) at higher priority ... |
f896c76 to
5320561
Compare
|
Thanks Davanum Srinivas (@dims) and Benjamin Elder (@BenTheElder). Dropped the vendored CDI as you both suggested and re-tested on a GPU node. I’ve also gated the whole thing to gVisor-only for now so the NVIDIA toolkit mount only lands on gVisor pools, meaning a micro-VM pool that requests a GPU won’t pick up the nvidia toolkit. On uVM - I'll start looking into that |
37f756b to
39d9060
Compare
|
Benjamin Elder (@BenTheElder) Davanum Srinivas (@dims) Omer Yahud (@omeryahud) - I've updated this PR to reflect #496 (unprivileged ateom). I had a few issues getting the GPU CDI hooks working, but right now GPU pools keep exactly the same posture as any other gVisor worker - no user namespace, no procMount: Unmasked, and per-actor cgroup delegation still working. The issue was that only one CDI hook was failing: update-ldcache, which needs the private /proc; skipping just that hook (and writing the SONAME symlinks from DT_SONAME instead) avoids the issue I had. The other CDI hooks run unmodified. I also needed a debian:stable-slim gvisor image to run the cdi generation tool. I did a full test end to end on a cluster with a T4 node workerpool on GKE with different NVIDIA tooling to check compatibility: nvidia-smi, vectorAdd, nbody, and a PyTorch cuBLAS matmul, plus two workers with two GPUs each on one 4-GPU node to check isolation betweek workers on a single node. I've added details in the updated description. |
| // (see nvproxyGlobalArgs). Set once here at boot to avoid a data race on the | ||
| // concurrent RunWorkload path. | ||
| if gpuPresent() { | ||
| runscNvproxy = true |
There was a problem hiding this comment.
I don't like passing arguments in a back channel, let's pass it as a function argument to maybeInjectGPU.
You can leave the check here for logging purposes
There was a problem hiding this comment.
instead of plumbing it I dropped the global entirely and had that call gpuPresent() directly, kept the check
| // maybeApplyGPUPodShape shapes a gVisor worker pod that requests a GPU so ateom | ||
| // can inject the GPU into actors via CDI. It mounts the host NVIDIA toolkit | ||
| // (version-matched to the node) — the glibc-based ateom image runs those toolkit | ||
| // binaries directly — and applies the user-namespace + unmasked-/proc posture the |
There was a problem hiding this comment.
Is this correct? A left-over from a previous implementation?
There was a problem hiding this comment.
Yes, the host path was defaulting to the container path, so they’re split now (/usr/local/nvidia/toolkit vs /opt/nvidia-toolkit, which has to sit outside the device plugin’s read-only tree), and I fixed a stale comment beside it still claiming the function applies a userns + unmasked /proc.
01a3e03 to
c5c0ab6
Compare
|
Nice work. The pause container getting --nvproxy at create, the cgroup device allow rules, and the NVIDIA_VISIBLE_DEVICES strip are all subtle and correct. One question before the details: what happens to the golden snapshot when the workload holds a live CUDA context at suspend time? Doesn't gVisor refuses to save a sandbox with open GPU state? can we cross check if the test plan accounts for this? |
| spec.Hooks = &specs.Hooks{} | ||
| } | ||
| for _, h := range edits.Hooks { | ||
| if h.HookName != "createContainer" || len(h.Args) < 2 || h.Args[1] == "update-ldcache" { |
There was a problem hiding this comment.
Should this be an allowlist and not a deny list? Newer toolkits could emit more hooks
There was a problem hiding this comment.
Sounds very reasonable, so I switched to an allowlist with logging of skipped hooks so we see them on newer toolkits as/when they come.
| only to the container that requests it. If per-container resource limits are added | ||
| later, GPU assignment should follow them. | ||
|
|
||
| **Requirements** |
There was a problem hiding this comment.
Do we need to say anything about LD_LIBRARY_PATH?
There was a problem hiding this comment.
Yes, added.
For context, CDI doesn’t emit LD_LIBRARY_PATH at all, the stock mechanism is the update-ldcache hook, which we skip for the unprivileged posture, so nothing puts the driver dir on the loader path. Every image I’d tested was an nvcr.io/nvidia/* one that sets it itself; override it and CUDA sees 0 devices even though the GPU is fully injected. Now prepending the driver library directory, with a unit test and a docs note.
| // SONAME symlinks (libcuda.so.1 -> libcuda.so.580.x) into the actor rootfs, which is | ||
| // what lets the GPU worker keep the plain unprivileged posture (no user namespace, no | ||
| // unmasked /proc). The CDI spec is plain JSON, so no CDI library is needed. | ||
| func injectGPUIntoBundle(ctx context.Context, bundleDir, cdiSpecDir string) error { |
There was a problem hiding this comment.
Does atelet always rewrite the bundle before each Run/Restore attempt? If not, a second call here doubles every device, mount, env, and hook. A guard that skips injection when an nvidia device is already in spec.Linux.Devices would make it safe either way.
There was a problem hiding this comment.
It does, but I added a guard so we don't depend on the atelet, plus a regression test, and confirmed the test fails without it.
| containerName, // Name of the container | ||
| ) | ||
| } | ||
| startArgs = append(startArgs, nvproxyGlobalArgs()...) |
There was a problem hiding this comment.
Is --nvproxy needed on start? do we want to be consistent where all we add --nvproxy?
There was a problem hiding this comment.
I thought so, but I tested removing it and it worked, kept it on create and restore, which actually boot the sentry process. start acts on a sandbox that already exists, so the flag has no effect there.
| const ( | ||
| // driverLibDir is where the CDI/GKE device plugin injects the driver user-mode | ||
| // libraries into a GPU pod; nvidia-ctk needs it on its library search path. | ||
| driverLibDir = "/usr/local/nvidia/lib64" |
There was a problem hiding this comment.
do we need this to be overridable?
There was a problem hiding this comment.
Yes, both of the variables you commented on now derive from one ATE_NVIDIA_DRIVER_ROOT (default /usr/local/nvidia).
| // driverBinDir holds the driver binaries (nvidia-smi, ...). nvidia-ctk discovers | ||
| // them via PATH, so we prepend this when generating the CDI spec — otherwise the | ||
| // spec carries only libraries and the actor has no nvidia-smi. | ||
| driverBinDir = "/usr/local/nvidia/bin" |
There was a problem hiding this comment.
Yes, both of the variables you commented on now derive from one ATE_NVIDIA_DRIVER_ROOT (default /usr/local/nvidia).
| // directly. The pod keeps the same security posture as any other gVisor worker. | ||
| // No-op for non-GPU pools and non-gVisor classes; an empty class defaults to | ||
| // gVisor (WorkerPoolSpec kubebuilder default). | ||
| func maybeApplyGPUPodShape( |
There was a problem hiding this comment.
Hmm, A microvm pool that requests a GPU silently gets none, should this fail harder? earlier?
There was a problem hiding this comment.
So to mitigate microVM pools with GPUs until/when actors there get support, I added a CEL rule on WorkerPoolSpec + tests - https://github.com/agent-substrate/substrate/pull/502/changes#diff-1ede08f8c093df5328ddbd446e4621241be469d4970f73b824426b601b35bab3R54
Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
c5c0ab6 to
22f4b82
Compare
Generate a per-pod CDI spec with nvidia-ctk, parse it in-tree, and inject the device nodes (major/minor stat-resolved) of the "all" CDI device, the driver- library mounts, and env into each actor's OCI spec. Run the CDI createContainer hooks from the mounted toolkit except update-ldcache, whose ldconfig needs a private /proc mount; stage the SONAME symlinks it would create from each library's ELF DT_SONAME instead. That keeps the GPU worker on the same unprivileged posture as any other gVisor worker (no user namespace, no procMount:Unmasked, cgroup delegation intact). Enable runsc --nvproxy at sandbox creation. Detect GPUs by device-node glob so any assigned index works (multi-GPU). The distroless ateom cannot exec nvidia-ctk, so GPU pools must run a glibc ateom build (WorkerPool.spec.ateomImage). Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
GPU passthrough is implemented only for the gVisor runtime, but the pod template's resources are applied before the sandbox-class check, so a micro-VM pool requesting nvidia.com/gpu still got the request on its worker pod. The pod then scheduled onto a GPU node and held a device that no actor could use. Reject the combination at apply time with a CEL rule on WorkerPoolSpec, matching the cross-field rules ActorTemplate already carries. The rule keys off limits or requests, mirroring the pod-shaping check, and is written positively so a future sandbox class must opt in rather than silently inherit GPU support. Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
22f4b82 to
63b24c8
Compare
Davanum Srinivas (@dims) Good catch as the test plan didn’t cover it, and with a live context the checkpoint fails. |
eliranw will leave this to the maintainers of this repo! :) |
Summary
atecontrollerpropagates a pool'snvidia.com/gpurequest onto theateomcontainer and mounts the host NVIDIA toolkit read-only (path overridable viaATE_NVIDIA_TOOLKIT_HOST_PATH)ateom-gvisorgenerates a CDI spec withnvidia-ctkand injects the device nodes, driver-library mounts, and env into each actor container's OCI speccreateContainerhooks exceptupdate-ldcachewhich needs a privileged ateom, staging the SONAME symlinks it would create from each library's ELFDT_SONAMErunsc --nvproxyat sandbox creationRequesting
nvidia.com/gpuon the pool is the only configuration needed; a pool that requests N GPUs makes all N usable.Two details of the CDI spec are worth calling out, because getting either wrong fails at runtime rather than at parse time.
nvidia-ctkleavesmajor/minorunset — CDI delegates that to the OCI runtime — so each device node is resolved by stat-ing the host; without it the actor gets0,0char devices and NVML reports it cannot communicate with the driver. And it emits per-index, per-UUID, andalldevices that repeat the same nodes, so onlyallis applied. The spec is plain JSON, soencoding/jsonsuffices and no CDI library is vendored.update-ldcacheis the one hook that cannot run here: itsldconfigunshares a mount namespace and mounts a private/proc, whichmount_too_revealing()rejects under the pod's masked/proc. Permitting it would needprocMount: Unmasked, which Kubernetes only allows withhostUsers: false, and that user namespace breaks the per-actor cgroup delegation from #496. Skipping it avoids the whole chain, so a GPU worker keeps the same posture as any other unprivileged gVisor worker.create-symlinksandenable-cuda-compatstill run unmodified.--nvproxymust be set when the sandbox is created — thepausecontainer, which holds no GPU devices — so runsc's auto-detection never fires on its own; without the flag the GPU subcontainer crashes the sentry on start. GPU detection matches any device index rather than assuming/dev/nvidia0, since a worker sharing a multi-GPU node can be assigned/dev/nvidia2and/dev/nvidia3.GPU pools must set
spec.ateomImageto a glibc build (KO_DEFAULTBASEIMAGE=debian:stable-slim ko build ./cmd/ateom-gvisor) because the distroless default cannot execnvidia-ctk; the default base is unchanged for every other pool.ateletalso has to run on the GPU nodes to restore actors there, so its DaemonSet needs a toleration for whatever taint they carry. Both are documented in the API guide rather than defaulted.Testing
make testenv -u NO_COLOR make verifynvidia-smi,vectorAdd,nbodyat 3.77 TFLOP/s, PyTorch matmul via cuBLAS at 3.5 TFLOP/s (T4 peak FP32 is ~8.1, so no measurable sandbox penalty)Snapshot and restore work when the workload holds no CUDA context. A live CUDA context cannot be checkpointed - gVisor fails with
can't save with live nvproxy clientsand the failed checkpoint terminates the sandbox so a GPU actor can only be suspended between CUDA workloads. Documented as a known limitation in the APIguide; a follow-up issue will track lifting it via
cuda-checkpoint.Fixes #627