feat(nvml-mock): serve the rendered PCI sysfs tree and a DMI identity to Go consumers - #674
feat(nvml-mock): serve the rendered PCI sysfs tree and a DMI identity to Go consumers#674giuliocalzo wants to merge 11 commits into
Conversation
c3fde6c to
96e6c96
Compare
… to Go consumers The rendered PCI tree was reachable only through MOCK_PCI_ROOT and the libpcimocksys.so LD_PRELOAD shim, which works for libc consumers such as lspci but not for Go: os.Open issues openat directly, the shim never sees it, and the process reads the node's real /sys. GPU Feature Discovery and the DRA driver are both Go, so GFD failed to read a mock GPU's vendor and class and labelled the node nvidia.com/gpu.mode=unknown. Nothing mocked the node's DMI identity either, leaving nvidia.com/gpu.machine unknown as well. Bind-mount sys/devices and sys/bus/pci/devices from the overlay onto the kernel paths, through both channels that serve containers: the CDI spec setup.sh generates and the NRI plugin's adjustment. Both mounts go together because the PCI entries are relative symlinks into ../../../devices, so one alone leaves every attribute read at ENOENT. /sys/devices is mounted whole: narrowing it to the profile's root complexes would need mountpoints the runtime cannot create on a read-only sysfs, and container creation fails outright. That hides the host's other device classes from served containers, so nri.pciSysfsMounts=false opts out. Profiles may now declare a dmi.product_name, which the renderer writes to sys/devices/virtual/dmi/id/product_name — where /sys/class/dmi/id points, and inside the only subtree a container can be handed. The NVIDIA platform profiles set it; l40s and t4 do not, since a commodity server's machine type belongs to the chassis rather than the GPU. The PCI render moves ahead of CDI spec generation: a bind mount whose source is missing fails container creation for the whole pod. Fixes NVIDIA#673 Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
GFD's default machine-type file is /sys/class/dmi/id/product_name, and /sys/class/dmi/id is a symlink to ../../devices/virtual/dmi/id — inside the subtree the mock already mounts. So setting GFD_MACHINE_TYPE_FILE was redundant wherever the kernel exposes DMI, which is every host the e2e suite runs on in CI. Drop it from the GPU Operator overlays and document the symlink instead, keeping the flag as the fallback for kernels with no DMI at all (Docker Desktop's linuxkit VM), where /sys/class/dmi does not exist and no mount can create it on a read-only sysfs. The gpu.machine assertion moves to its own spec and skips when the container cannot reach DMI through GFD's default path: the label is then "unknown" for a reason the mock does not control. Reading the rendered file from inside the container still runs unconditionally, so delivery stays pinned everywhere. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
Serving the rendered PCI tree at the kernel paths is how a mock node presents GPUs to consumers that read sysfs; it is not a tuneable. A node running nvml-mock is simulating GPU hardware, so a knob that turns the simulation half off only adds a way to end up with GFD labelling gpu.mode=unknown for a reason nothing reports. Removes nri.pciSysfsMounts, the --disable-pci-sysfs-mounts flag and its config field. Workloads that need the host's real device tree on an NRI-injected node still have nri.excludedNamespaces, which the README now points at instead. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
The mock renders the machine type where /sys/class/dmi/id points, so GFD resolves it through its own default path and the env var is not part of the contract. Describing it as the remedy for a DMI-less kernel invited exactly the configuration this design removed; the cases that read "unknown" are still spelled out, just without the recipe. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
…ices Bind-mounting the rendered tree over /sys/devices also replaces virtual/dmi/id, the directory /sys/class/dmi/id resolves into. kind registers /kind/bin/mount-product-files.sh as a createContainer hook for every container on the node, and wherever the host kernel exposes DMI it bind-mounts the node's product_name and product_uuid there. mount(8) cannot create a target on a read-only sysfs, so every served pod died with mount: .../sys/class/dmi/id/product_uuid: mount point does not exist failing all e2e legs on Linux while passing on Docker Desktop, whose linuxkit VM exposes no DMI at all. The renderer now mirrors those attributes out of the kernel's DMI directory, so the targets survive the shadowing and the node keeps reporting its own identity. Mocking the machine type comes out of this change: kind fakes product_name to the literal "kind" for the node and, through the same hook, for every container, so no value the renderer writes reaches GFD's default machine-type file. Tracked in NVIDIA#681. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
The comment said gpu.mode was blocked on an unrelated fix, which NVIDIA#672 landed on main. What the spec pins is unchanged — that the tree reaches the container — so say only that. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
96e6c96 to
1d4400a
Compare
ArangoGutierrez
left a comment
There was a problem hiding this comment.
Nice piece of debugging, and the writeup makes the root cause easy to follow: the LD_PRELOAD shim can't see Go's openat, so only a real mount at the kernel path works. The renderer split into renderTopology/renderDMI is clean, the fail-open guards on both the CDI and NRI sides are the right instinct, and the e2e that reads vendor through the symlink from inside the GFD container is exactly the assertion that distinguishes delivery from coincidence. I also like that renderDMI mirrors rather than mocks, and says so. The one thing I'd want settled before merge is the blast radius of the NRI mount: it is node-wide where the CDI path is opt-in by construction, and dropping nri.pciSysfsMounts in 853cea2 removed the only switch for it.
- The gate checks that the two directories exist, not that this run rendered them, and the renderer only adds entries. Re-profiling a node (a100 and h100 share no BDFs) leaves both sets under $HOST/sys, the gate still flips on, and the union is what gets bind-mounted at /sys/bus/pci/devices. The missing prune predates this PR, but mounting the tree at the kernel path is what makes it visible, and the exact-count assertion in PCISysfsAtKernelPath would fail on such a node. (deployments/nvml-mock/scripts/setup.sh:140)
- While you're rewriting the row above it, this one is now stale too: the renderer writes numa_node for every device, and it reaches served containers through the same mount, so the device plugin does get NUMA hints there. Still accurate for containers the mock doesn't serve, so it probably wants the same qualifier the row above gained. (deployments/nvml-mock/helm/nvml-mock/README.md:1409)
Three ways the served tree could describe something other than the profile the node is running, all found in review of NVIDIA#674. Rendering only ever added entries. Re-profiling a node (an a100 and an h100 share no BDFs) left both sets in place and consumers saw their union mounted at /sys/bus/pci/devices — more GPUs than the node simulates, some under a root complex no profile declares. Each render now prunes first. The staged-tree guards stat the directories they mount, which the renderer creates at the very start while the DMI attributes kind's createContainer hook needs are written at its end. A tree caught mid-render passed, got mounted, and then failed container creation on a missing bind target — the failure the guard exists to prevent. The renderer marks completion with sys/.rendered and both the NRI plugin and setup.sh gate on that instead. The prune keeps the two mounted directories and the DMI directory in place for the same reason, since a CDI-served container cannot wait for a marker: the runtime applies the spec's mounts unconditionally. product_uuid was mirrored by value at mode 0644, republishing a node identifier the kernel deliberately exposes 0400 to root alone into every served container. Only the file's existence matters — kind bind-mounts the node's own copy over it — so it is now an empty stand-in, and product_name alone travels by value. The chart README's numa_node limitation gains the qualifier the row above it already has: the renderer writes numa_node for every device and it reaches served containers, so a served device plugin does get NUMA hints. The node-wide NRI mount also names nri.optOutAnnotation, which exempts a single workload, next to the namespace-level nri.excludedNamespaces. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
FirstPodName returns Items[0] with no phase filter, so a Terminating or Pending GFD pod matches the selector as readily as the Running one and the exec into it fails. Nor is Items[0] necessarily on the node the surrounding specs assert about, so the spec could report about hardware it never checked labels for. RunningPodOnNode adds both filters, which is what a caller execing into a DaemonSet's pod wants. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
|
Thanks — all five actionable points are addressed in ffeef77 and 2c4b3dd, and I answered the blast-radius question in its thread (short version: gating on On the two numbered points:
|
|
|
||
| ### PCI sysfs in containers | ||
|
|
||
| Reaching the tree through `MOCK_PCI_ROOT` requires the `libpcimocksys.so` |
There was a problem hiding this comment.
So since we are doing mounting of sys directories, we don't need to use the previous way of mocking and we can remove it? Mounting should work for both type of consumers right?
There was a problem hiding this comment.
Sorry for the slow reply — these landed while I was answering the other thread.
For the consumers this PR is about, yes: a Go consumer in a container the mock serves now reads the real thing at the kernel path, and the shim adds nothing there. But the mount cannot replace the shim in general, because a bind mount needs a target that already exists — mount(8) cannot create one on a read-only sysfs, which is why this PR mounts /sys/devices whole instead of just the profile's root complexes.
That rules the mount out wherever the path is absent on the node:
/sys/class/infinibandand/sys/class/infiniband_verbsdo not exist on a node with no IB hardware and noib_core, so the IB tree is reachable only throughlibibmocksys. Same for/dev/infiniband./sys/class/dmiis absent on Docker Desktop's linuxkit VM, which is why this PR mirrors DMI into the tree rather than relying on the kernel's copy.
And it only reaches containers one of the two channels serves. The nvml-mock DaemonSet is never self-injected, so lspci in its own pod works through LD_PRELOAD + MOCK_PCI_ROOT (set in daemonset.yaml), as does anything in a pod that opted out with nvml-mock.nvidia.com/inject: "false" or lives in an excluded namespace.
So the two mechanisms answer different questions: the shim covers any libc consumer anywhere on the node, at any path; the mount covers the paths Go consumers read with raw syscalls, in the containers we serve.
| directory yields entries that list but whose every attribute read fails with | ||
| `ENOENT`. | ||
|
|
||
| **Trade-off:** `/sys/devices` is mounted whole, which hides the host's other |
There was a problem hiding this comment.
This means that we should take the real node's devices file and "expand" it with simGPU devices in order to produce /var/lib/nvml-mock/sys/devices. This way all devices should be there.
This should work even if host devices file is not static. We could fnotify watch it and mirror in our produced devices file.
There was a problem hiding this comment.
I dug into this and filed #689 for it, because it deserves more than a paragraph here — but I do not think mirroring is the shape that works, for two reasons.
Copying gives frozen values. Sysfs attributes are kernel-backed reads, not files: scaling_cur_freq, thermal zones and every counter change between the read and the consumer's. A mirrored /sys/devices/system/cpu would look right and be stale, which is worse than a missing directory that fails loudly. Live mirroring would have to bind-mount each host entry into our tree rather than copy it — doable from a privileged DaemonSet, but then it is mounts all the way down, and inotify on sysfs does not reliably report attribute changes, so the watch would not be the part that works.
The harder problem is that no mirror of the node's tree can be right. /sys/class/net/<iface> resolves into devices/virtual/net, and what belongs there is the pod's own net namespace view — the kernel populates it per netns at mount time. The node's copy holds the host's interfaces, so mirroring it hands the container someone else's network.
So the way out is to stop shadowing /sys/devices at all. If the renderer emitted symlink targets that escape sysfs into the overlay we already mount — /sys/bus/pci/devices/0000:07:00.0 -> ../../../../opt/nvml-mock/sys/devices/pci0000:00/0000:07:00.0 — then only /sys/bus/pci/devices needs mounting, a path every node has, and CPU topology, DMI and the pod's namespaced sysfs are all left alone. That is #689; the open question is whether the DRA driver's deviceattribute still finds the PCIe root in a path that does not start with /sys/devices.
ArangoGutierrez
left a comment
There was a problem hiding this comment.
Nothing here blocks merge.
The marker protocol is the right shape. Writing sys/.rendered last and gating both channels on it makes "this render finished" distinguishable from "the directories exist", and pruneTree keeping the two mounted directories and virtual/dmi/id in place is what lets a CDI-served container survive a re-render it has no way to wait for. product_uuid as an empty stand-in and product_name mirrored rather than mocked both land, and the new render tests each name a bug they would catch: the prune test goes red on the old additive renderer, the DMI tests on a mount target that stops existing. Moving the render to step 3b checks out too, since nothing between there and the old step 10 writes under $HOST/sys, and the renderer reads the ConfigMap at /etc/nvml-mock/config.yaml rather than the copy step 5 makes. Where the protocol still has a hole is the one path that never reaches the renderer at all.
- The
Outputgodoc still says Render is a no-op when Topology is nil or has no root complexes; that path now prunes the tree and deletes the marker whenever Output is set. (pkg/system/mockpcisysfs/render/render.go:55) MarkerRelPathis exported from render for exactly this seam, but these two path constants are re-declared here and staged with their own literals in adjust_test.go, so a rename on the renderer side would leavepciSysfsMountsstatting a path that no longer exists and failing open silently, with nothing red. (pkg/nri/nvmlmock/adjust.go:50)
| if o.Output == "" { | ||
| return nil | ||
| } | ||
| return pruneTree(o.Output) |
There was a problem hiding this comment.
main.go returns at its topo == nil check before Render is ever called, and EffectiveTopology is nil whenever no device declares a pci.bus_id, which gpu.customConfig makes reachable, so this prune never runs on the production path. Rendering a100 and then such a config into the same output directory leaves all eight A100 symlinks and sys/.rendered in place, so the gate in setup.sh flips PCI_SYSFS_RENDERED=on and both channels mount the previous profile's tree at the kernel paths; calling Render unconditionally from main.go, or clearing the marker on that early return, closes it.
Also at deployments/nvml-mock/scripts/setup.sh:146.
There was a problem hiding this comment.
Correct, and this was the hole worth having found: the prune shipped in a commit whose own entry point could not reach it on that path. Fixed in 6b827e3.
render-pci-sysfs now calls Render unconditionally — a nil topology is already "empty the tree" to the renderer, so the command has no reason to decide that for itself — and --dry-run is the only short-circuit left. main is split into a run() so the case that regressed has a test: TestRun_ClearsTreeWhenProfileDeclaresNoDevices renders a profile with a bus_id and then one without, and asserts both the devices and the marker are gone. Against the early return it fails with Should be empty, but was [L 0000:07:00.0], which is the a100-then-customConfig sequence you described.
setup.sh's marker comment now also says the renderer removes the marker for a profile that renders nothing, since that is what makes the gate at :146 correct rather than the gate itself doing anything new.
| return "", err | ||
| } | ||
| for _, p := range pl.Items { | ||
| if p.Status.Phase == "Running" { |
There was a problem hiding this comment.
A terminating pod keeps status.phase: Running until its containers exit, deletionTimestamp is what marks it, and the objectMeta this decodes into carries only name, labels and annotations, so the Terminating half of the doc above is not enforced and a GFD rollout can still hand back the pod that is going away. The call site resolves it once with no Eventually either, unlike waitOperatorValidatorRunning and the dcgm assertion, which poll because the operator replaces its operands a reconcile after nvml-mock rolls (#602).
There was a problem hiding this comment.
Both true — fixed in 9974d6f. My doc claimed a filter the type could not express: objectMeta now decodes deletionTimestamp, and the pod has to be phase Running and not have one, so the pod on its way out is skipped rather than exec'd into.
The call site polls too, with ReadyTimeout/PollInterval, in the same shape as waitOperatorValidatorRunning. Resolving once assumed a steady state that #602 says does not hold here: the operator replaces its operands a reconcile after nvml-mock rolls, so there is a window with no candidate at all, and skipping the terminating pod turns that window from a failed exec into an empty result — which only helps if the caller waits it out.
render-pci-sysfs returned before Render whenever the profile yielded no topology, which is any profile whose devices declare no pci.bus_id — a config gpu.customConfig makes reachable. The prune and the completion marker therefore never ran on that path: re-profiling a node from a100 onto such a config left all eight A100 symlinks and sys/.rendered on disk, so setup.sh flipped PCI_SYSFS_RENDERED=on and both channels went on mounting the previous profile's devices at the kernel paths. Render already treats a nil topology as "empty the tree", so the command now calls it unconditionally and only --dry-run short-circuits. main is split into a testable run() to cover the case that regressed. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
The NRI guard re-declared both halves of the tree and its tests staged them from matching literals, so renaming a path in the renderer would have left pciSysfsMounts statting something nothing writes — and it fails open, dropping the mounts with no test going red. MarkerRelPath was already exported for this seam; the two directories now are too. Also drops the Options.Output claim that Render is a no-op without a topology, which stopped being true when that path started pruning. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
A terminating pod reports phase Running until its containers exit, so the phase filter alone still handed back the pod going away; the objectMeta these decode into now carries deletionTimestamp, which is what marks it. The call site polls for the same reason waitOperatorValidatorRunning does: the operator replaces its operands a reconcile after nvml-mock rolls, so resolving the pod once can land in the gap where none is ready. Signed-off-by: Giulio Calzolari <gcalzolari@nvidia.com>
|
All four addressed. The two you numbered:
And the two inline: the I also answered @roma-glushko's two questions from yesterday, which I had missed. The second one — mirroring the node's
|
@giuliocalzo make sense to move this question out of scope, but I would think a bit more about what we can do there. |
Summary
The mock renders a PCI sysfs tree, but only libc consumers could read it:
MOCK_PCI_ROOTworks through thelibpcimocksys.soLD_PRELOADshim, and Go'sospackage issuesopenatdirectly, so the shim never sees the open and the process reads the node's real/sys. GPU Feature Discovery and the NVIDIA DRA driver are both Go. GFD therefore resolved each mock GPU's BDF from NVML, failed to read itsvendor/class, and labelled the nodenvidia.com/gpu.mode=unknown.sys/devicesandsys/bus/pci/devicesare bind-mounted read-only onto the kernel paths, in both channels that serve containers: the CDI specsetup.shgenerates (which is how GFD already receives the mocklibnvidia-ml.so.1) and the NRI plugin's adjustment. They go together — the PCI entries are relative symlinks into../../../devices/pciDDDD:BB, so mounting one alone yields entries that list but whose every attribute read returnsENOENT, which looks identical to no mount at all.product_nameandproduct_uuidintosys/devices/virtual/dmi/id. This is mount-target compatibility, not a machine-type mock: shadowing/sys/devicesreplaces the directory/sys/class/dmi/idresolves into, and kind'smount-product-files.shcreateContainerhook bind-mounts the node's product files there for every container.mount(8)cannot create a target on a read-only sysfs, so without the mirror every served pod died withmount: .../sys/class/dmi/id/product_uuid: mount point does not exist— on Linux only, since Docker Desktop's linuxkit VM exposes no DMI and kind's hook guards on the host kernel having it.There is no opt-out. A node running nvml-mock is simulating GPU hardware, and serving the tree is what makes that simulation visible to sysfs consumers; a switch that turns it half off only adds a way to end up with
gpu.mode=unknownfor a reason nothing reports. Workloads that need the host's real device tree on an NRI-injected node belong innri.excludedNamespaces.One read-only-sysfs constraint shapes the rest of the design, and it is worth stating because it rules out the obvious alternative.
/sys/deviceshas to be mounted whole: narrowing it to the profile's root complexes needs a mountpoint the runtime cannot create, and it fails hard rather than degrading —which profiles routinely trigger (
gb200renders four root complexes on a node whose/sys/deviceshas none). That mount hides the host's other device classes, CPU topology among them, from served containers.Fixes #673.
Not in scope:
nvidia.com/gpu.machine. Mocking the machine type turned out to have a cause of its own — kind serves its ownproduct_nameat GFD's default machine-type file in every container, so no value the renderer writes there survives — and it is tracked separately in #681.Test plan
TestRender_MirrorsKernelDMI,TestRender_StandsInForUnreadableDMI,TestRender_NoDMIWithoutKernelDMI— the mirrored attributes, the empty stand-in forproduct_uuid(mode0400, unreadable to a non-root renderer but still needed as a mount target), and no DMI directory at all on a kernel that exposes none.TestAdjustMountsPCISysfsWhenStaged/...SkipsPCISysfsMountsWhenNotStaged/...SkipsPCIDevicesMountWithoutSysDevices— the pair is emitted together, and skipped fail-open on a node the DaemonSet has not staged yet (a bind mount with a missing source would fail container creation for the whole pod).vendorread that only resolves through the symlink into the mounted/sys/devices.make test— 33 packages pass.make helm-tests— 169 tests, 15 snapshots.make lint-fix— no violations (govulncheckreports pre-existing Go stdlib advisories from the local toolchain, unrelated).Runningwith 0 restarts,nvidia.com/gpu.mode=computewhere it previously readunknown,gpu.product=NVIDIA-GB200with 8 allocatable GPUs. Inside thegpu-feature-discoverycontainer,/sys/bus/pci/deviceslists 8 entries, the first symlinking to../../../devices/pci0000:00/0000:0a:00.0, which readsvendor 0x10de,class 0x030200,numa_node 0— the class being what GFD turns intomode=compute, so the label is derived from the mounted tree rather than coincidence. That host is linuxkit, so the mirror correctly writes nothing andgpu.machinestaysunknown([Bug]: nvidia.com/gpu.machine cannot be mocked under kind — the node image shadows /sys/class/dmi/id/product_name #681); the Linux legs in CI cover the kind-hook path that the mirror exists for.