-
Notifications
You must be signed in to change notification settings - Fork 34
feat(nvml-mock): serve the rendered PCI sysfs tree and a DMI identity to Go consumers #674
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5a137c9
0a24b24
853cea2
8fa144b
3b7d683
1d4400a
ffeef77
2c4b3dd
6b827e3
047d6e8
9974d6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| // Copyright 2026 NVIDIA CORPORATION | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package main | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/NVIDIA/k8s-test-infra/pkg/system/mockpcisysfs/render" | ||
| ) | ||
|
|
||
| const profileWithDevices = ` | ||
| devices: | ||
| - index: 0 | ||
| pci: | ||
| bus_id: "0000:07:00.0" | ||
| ` | ||
|
|
||
| // A profile whose devices declare no bus_id renders no topology. It is | ||
| // reachable through gpu.customConfig, and it used to return before Render. | ||
| const profileWithoutBusIDs = ` | ||
| devices: | ||
| - index: 0 | ||
| ` | ||
|
|
||
| // TestRun_ClearsTreeWhenProfileDeclaresNoDevices covers re-profiling a node | ||
| // onto a profile with nothing to render. The tree and the completion marker | ||
| // left by the previous profile would otherwise stay on disk, and both serving | ||
| // channels would keep mounting devices this profile does not declare — | ||
| // setup.sh gates on the marker, which said "rendered" about the old tree. | ||
| func TestRun_ClearsTreeWhenProfileDeclaresNoDevices(t *testing.T) { | ||
| out := t.TempDir() | ||
| require.NoError(t, run(options{ | ||
| configPath: writeProfile(t, profileWithDevices), | ||
| outputDir: out, | ||
| }), "render a profile with devices") | ||
| require.FileExists(t, filepath.Join(out, render.MarkerRelPath), "marker after the first render") | ||
|
|
||
| require.NoError(t, run(options{ | ||
| configPath: writeProfile(t, profileWithoutBusIDs), | ||
| outputDir: out, | ||
| }), "render a profile without devices") | ||
|
|
||
| entries, err := os.ReadDir(filepath.Join(out, render.PCIDevicesRelPath)) | ||
| require.NoError(t, err, "read devices dir") | ||
| require.Empty(t, entries, "the previous profile's devices are still served") | ||
| require.NoFileExists(t, filepath.Join(out, render.MarkerRelPath), | ||
| "the marker still claims a rendered tree") | ||
| } | ||
|
|
||
| // TestRun_DryRunWritesNothing pins that --dry-run stays a validation pass on | ||
| // both paths, including the one that now prunes. | ||
| func TestRun_DryRunWritesNothing(t *testing.T) { | ||
| for name, profile := range map[string]string{ | ||
| "with devices": profileWithDevices, | ||
| "without devices": profileWithoutBusIDs, | ||
| } { | ||
| t.Run(name, func(t *testing.T) { | ||
| out := t.TempDir() | ||
| require.NoError(t, run(options{ | ||
| configPath: writeProfile(t, profile), | ||
| outputDir: out, | ||
| dryRun: true, | ||
| }), "dry run") | ||
| entries, err := os.ReadDir(out) | ||
| require.NoError(t, err, "read output dir") | ||
| require.Empty(t, entries, "--dry-run wrote to the output directory") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func writeProfile(t *testing.T, contents string) string { | ||
| t.Helper() | ||
| path := filepath.Join(t.TempDir(), "config.yaml") | ||
| require.NoError(t, os.WriteFile(path, []byte(contents), 0o644), "write profile") | ||
| return path | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,10 +21,11 @@ Deploys a DaemonSet that creates on every node: | |
| - A fake PCI sysfs tree at `/var/lib/nvml-mock/sys/bus/pci/devices/...` (symlinks | ||
| into `/var/lib/nvml-mock/sys/devices/pciDDDD:BB/...`) so C consumers of the | ||
| PCI sysfs — `lspci` and anything else reaching it through libc — resolve the | ||
| PCIe root complex via a standard `readlink()`. The NVIDIA DRA driver is a Go | ||
| binary and does not see this tree, so `dra.k8s.io/pcieRoot` is still absent | ||
| from its ResourceSlices; see [Known Limitations](#known-limitations) and | ||
| issue [#265](https://github.com/NVIDIA/k8s-test-infra/issues/265) | ||
| PCIe root complex via a standard `readlink()`. Consumers written in Go read | ||
| sysfs with direct syscalls no `LD_PRELOAD` shim can intercept, so the two | ||
| directories are additionally bind-mounted onto `/sys/bus/pci/devices` and | ||
| `/sys/devices` in served containers (see | ||
| [PCI sysfs in containers](#pci-sysfs-in-containers)) | ||
|
|
||
| Consumers (DRA driver, device plugin) point at `/var/lib/nvml-mock/driver` | ||
| as the NVIDIA driver root and discover GPUs through standard NVML APIs. | ||
|
|
@@ -708,6 +709,53 @@ DaemonSet under `set -e` if it finds a typo: | |
| If a profile omits `pcie_topology:` entirely the renderer falls back to | ||
| a flat single-root layout (every device under `pci0000:00`, NUMA 0). | ||
|
|
||
| ### PCI sysfs in containers | ||
|
|
||
| Reaching the tree through `MOCK_PCI_ROOT` requires the `libpcimocksys.so` | ||
| `LD_PRELOAD` shim, which only works for consumers that go through libc. A Go | ||
| program does not: `os.Open` issues `openat` directly, the shim never sees it, | ||
| and the process reads the node's real `/sys` — where the mock GPUs do not | ||
| exist. GPU Feature Discovery and the NVIDIA DRA driver are both Go. | ||
|
|
||
| So the rendered directories are bind-mounted read-only onto the kernel paths | ||
| in containers the mock serves, through the CDI spec the DaemonSet generates at | ||
| `/var/run/cdi/nvidia.yaml` and, when `nri.enabled=true`, through the NRI | ||
| plugin's container adjustment: | ||
|
|
||
| | Host | Container | | ||
| |---|---| | ||
| | `/var/lib/nvml-mock/sys/devices` | `/sys/devices` | | ||
| | `/var/lib/nvml-mock/sys/bus/pci/devices` | `/sys/bus/pci/devices` | | ||
|
|
||
| Both are needed together: the entries under `/sys/bus/pci/devices` are | ||
| relative symlinks into `../../../devices/pciDDDD:BB`, so mounting only that | ||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This means that we should take the real node's devices file and "expand" it with simGPU devices in order to produce This should work even if host devices file is not static. We could fnotify watch it and mirror in our produced devices file.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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: The harder problem is that no mirror of the node's tree can be right. So the way out is to stop shadowing |
||
| device classes (CPU topology among them) from those containers. It cannot be | ||
| narrowed to the profile's root complexes — a bind mount at a path sysfs does | ||
| not already have needs a mountpoint, and the runtime cannot create one on a | ||
| read-only `/sys`. A node running nvml-mock is simulating GPU hardware, so | ||
| serving the tree is not itself configurable. Through CDI a container is served | ||
| only if it requests a mock GPU. Through NRI, which injects ambiently, the two | ||
| existing escape hatches cover it: the pod annotation | ||
| `nvml-mock.nvidia.com/inject: "false"` (`nri.optOutAnnotation`) exempts a | ||
| single workload, and `nri.excludedNamespaces` exempts a whole namespace. | ||
|
|
||
| `/sys/devices/virtual/dmi/id` — the directory `/sys/class/dmi/id` resolves | ||
| into — is shadowed along with the rest, so the renderer mirrors the node's | ||
| `product_name` there and leaves an empty `product_uuid` beside it. This is not | ||
| cosmetic: kind's `mount-product-files.sh` createContainer hook bind-mounts the | ||
| node's copies of both onto every container it starts, and `mount(8)` cannot | ||
| create a target on a read-only sysfs, so a missing attribute fails container | ||
| creation for every pod the mock serves. `product_uuid` is a node identifier the | ||
| kernel exposes to root alone and kind mounts its own copy over it, so only the | ||
| target is reproduced, never the value. `product_name` is mirrored, not mocked — | ||
| a node keeps reporting its own machine type, which under kind is the literal | ||
| `kind`, so `nvidia.com/gpu.machine` does not follow the profile. Tracked in | ||
| [#681](https://github.com/NVIDIA/k8s-test-infra/issues/681). | ||
|
|
||
| ### Cross-node `ibping` | ||
|
|
||
| Sysfs mocking alone lets `ibstat` / `iblinkinfo` work, but real `ibping` | ||
|
|
@@ -1362,8 +1410,8 @@ discovery and monitoring. Some host-level subsystems are not mocked: | |
|
|
||
| | What's Missing | Affected Consumer | Impact | | ||
| |----------------|-------------------|--------| | ||
| | `/sys/bus/pci/devices/{busID}` sysfs entries **as a Go program reads them** | DRA driver | The tree is rendered and `lspci` reads it, but the driver is a Go binary: Go's `os` package issues raw syscalls that the `LD_PRELOAD` shim cannot intercept, so it reads the host's real sysfs instead. `dra.k8s.io/pcieRoot` stays absent from ResourceSlices — **blocks topology-aware scheduling demos** (e.g., GPU + SR-IOV VF alignment). Tracked in [#265](https://github.com/NVIDIA/k8s-test-infra/issues/265) | | ||
| | `/sys/bus/pci/devices/{busID}/numa_node` | Device plugin | NUMA-aware topology hints unavailable; scheduling works but NUMA affinity not enforced | | ||
| | `/sys/bus/pci/devices/{busID}` sysfs entries in containers the mock does **not** serve | DRA driver | The tree is now bind-mounted onto the kernel paths for CDI- and NRI-served containers, which is what Go consumers need (see [PCI sysfs in containers](#pci-sysfs-in-containers)). A consumer deployed outside those channels still reads the host's real sysfs; whether `dra.k8s.io/pcieRoot` reaches ResourceSlices is tracked in [#265](https://github.com/NVIDIA/k8s-test-infra/issues/265) | | ||
| | `/sys/bus/pci/devices/{busID}/numa_node` in containers the mock does **not** serve | Device plugin | The renderer writes `numa_node` for every device and it arrives through the same mount, so a served device plugin does get NUMA hints. Outside those channels the hints are unavailable: scheduling works but NUMA affinity is not enforced | | ||
| | `/sys/bus/pci/devices/*/vendor,device,class` **as NFD reads them** (`/host-sys/…`, fixed at link time) | NFD (Node Feature Discovery) | PCI feature labels not auto-detected. `nvidia.com/gpu.present` is written directly by nvml-mock; `pci-10de.present` is created by NFD from a feature file nvml-mock drops in `nodeLabels.featuresDir` — see [Node Labels](#node-labels) | | ||
|
|
||
| ### PCIe Root Complex (DRA driver) | ||
|
|
@@ -1377,11 +1425,12 @@ W0319 11:41:21.314205 1 nvlib.go:491] error getting PCIe root for device 0 | |
| readlink /sys/bus/pci/devices/0000:07:00.0: no such file or directory | ||
| ``` | ||
|
|
||
| **This warning is expected** but has real impact. The DRA driver resolves PCIe | ||
| root complex topology by reading sysfs symlinks. Since nvml-mock provides a mock | ||
| NVML library (not a full kernel driver), these sysfs entries don't exist. GPUs | ||
| appear in ResourceSlices and are fully allocatable, but the | ||
| `dra.k8s.io/pcieRoot` topology attribute is absent. | ||
| The DRA driver resolves PCIe root complex topology by reading sysfs symlinks. | ||
| The rendered tree now reaches served containers at `/sys/bus/pci/devices` (see | ||
| [PCI sysfs in containers](#pci-sysfs-in-containers)), so a driver the mock | ||
| serves resolves the root complex; one deployed outside the CDI and NRI paths | ||
| still reads the host's sysfs and logs the warning above, with GPUs allocatable | ||
| but `dra.k8s.io/pcieRoot` absent. | ||
|
|
||
| **What this blocks:** DRA topology-aware scheduling that uses `pcieRoot` to | ||
| align devices on the same PCIe root complex — for example, co-scheduling a GPU | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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/deviceswhole 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
lspciin its own pod works throughLD_PRELOAD+MOCK_PCI_ROOT(set indaemonset.yaml), as does anything in a pod that opted out withnvml-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.