Summary
When CUDA_VISIBLE_DEVICES restricts which GPUs are visible, a node reports the visible device's
name alongside a different device's memory, then advertises that wrong number to the mesh as its
placement budget.
Observed on a host with an RTX 5090 (device 0, 32607 MiB) and an RTX 3080 (device 1, 10240 MiB) with
CUDA_VISIBLE_DEVICES=1:
"my_vram_gb": 33.676066816,
"gpus": [{
"name": "NVIDIA GeForce RTX 3080",
"vram_bytes": 34190917632, // 32607 MiB = the RTX 5090's total
"rated_vram_gb": 32, // also the 5090
"reserved_bytes": 514850816,
"allocatable_vram_bytes": 33676066816
}]
libcuda under the identical environment sees only the 3080:
cuda visible device count: 1
cuda[0] NVIDIA GeForce RTX 3080 total=10354032640 bytes (9874 MiB)
So the node advertises ~33.7 GB on a card holding ~10.35 GB, about 3.3x over-advertisement, and it
propagates: the peer entry on another node showed the same vram_gb: 33.676066816.
Not a regression. The released v0.76.0 binary reproduces the identical numbers, verified by
running the published mesh-llm-v0.76.0-x86_64-unknown-linux-gnu-cuda-13.tar.gz (checksum verified)
against a locally built candidate on the same host with the same environment.
Root cause
Two positional joins between enumerations that do not share visibility semantics, all in
crates/mesh-llm-system/src/hardware/enrichers.rs (module linux):
cuda_device_infos() enumerates via libcuda, which honours CUDA_VISIBLE_DEVICES. With
=1, infos has one entry: the 3080.
merge_nvml_device_infos() iterates NVML with for index in 0..count. NVML ignores
CUDA_VISIBLE_DEVICES, so count == 2. It merges each NVML device into infos.get(index), so
NVML device 0 (the 5090) is merged into the 3080's slot.
match_nvidia_device() tries a PCI-BDF match, then falls back to infos.get(gpu.index). The
skippy-enumerated 3080 has index == 0, so it matches the now-contaminated slot, and
enrich_nvidia_gpu_facts() overwrites gpu.vram_bytes with the 5090's total.
The GPU name is unaffected because it comes from the skippy backend-device enumeration, which is
why the two disagree and make the bug visible.
Two things worth noting for the fix:
- The pre-enrichment value is already correct.
gpu_facts_from_backend_devices() in
skippy_devices.rs sets vram_bytes = device.memory_total from skippy, which reflects the visible
device. Enrichment then overwrites a correct value with a wrong one. Declining to enrich is
therefore a safe fallback, not a degradation.
- The PCI-BDF path is the correct one and it silently failed here. skippy does populate
pci_bdf = device.device_id, and is_placeholder_pci_bdf() exists, implying placeholders occur.
Establishing why the identity match missed is part of the work below.
Consequence
vram_bytes is the mesh placement budget and context size auto-scales to it, so the inflated budget
produced a real failure loading a 2.8 GB model:
[debug] backend: allocating 8704.00 MiB on device 0: cudaMalloc failed: out of memory
[warn ] warning: Native runtime reported a handled model-open failure
[error] error: Failed to start model unsloth/Qwen3.5-4B-GGUF:UD-Q4_K_XL: ... RuntimeError
Handled without a panic and the node stayed in the mesh. The /v1/models union correctly excluded
the failed model, so routing was not misled, but the peer still reported
serving_models: ["unsloth/Qwen3.5-4B-GGUF:UD-Q4_K_XL"] while llama_ready: false.
Scope
Any host where CUDA visibility is restricted and the visible device is not at the same global NVML
index: containers, Kubernetes with device plugins, and shared or scheduled multi-GPU machines. Those
are exactly the environments where a node is most likely to be handed a subset of the GPUs.
Introduced by
#509 (Report backend GPUs and bundle GPU benchmarks, merged 2026-05-12, commit 57cdac988), which
added enrichers.rs, the gpu.vram_bytes = total_bytes overwrite, and the infos.get(gpu.index)
fallback. git log -S places all three there.
Fix plan (TDD)
enrichers.rs currently has no test module and no test coverage. The plan below writes the tests
first at every stage. enrich_nvidia_gpu_facts() and match_nvidia_device() are already pure
functions taking &[NvidiaDeviceInfo], so most of this needs no refactor to become testable;
merge_nvml_device_infos() does need a seam.
Real two-GPU hardware is required for phases 0 and 4. Unit tests cannot prove the fix, because the
whole bug lives in the disagreement between two real driver enumerations. They can only lock in the
logic once the hardware tells us what the real inputs look like.
Phase 0 — Characterize on real hardware (before writing any fix)
Goal: capture the real GpuFacts and both driver enumerations on a two-GPU host, and answer the one
open question: why did the PCI-BDF match miss?
On a host with two NVIDIA GPUs of different memory sizes (validated here on an RTX 5090 +
RTX 3080), for each of CUDA_VISIBLE_DEVICES unset, 0, 1, and 1,0, record:
- skippy
device_id / pci_bdf and device.memory_total per device, pre-enrichment
cuda_device_infos() output: count, names, pci_bdf, total_bytes
- NVML output: count, UUIDs,
pci_bdf, total_bytes
- the final
/api/status gpus[]
- ground truth from libcuda and
nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id,uuid
Deliverable: a short table in this issue showing, for each setting, whether identities matched and
which slot each device landed in. Do not proceed until it is clear whether the PCI-BDF miss is a
formatting mismatch, a placeholder id, or an absent id, because that decides whether the fix must
also repair identity extraction rather than only remove the positional fallback.
Phase 1 — RED: unit tests that fail on today's code (no refactor needed)
Create mod tests in enrichers.rs, gated #[cfg(all(test, target_os = "linux"))], with a fixture
builder for GpuFacts and NvidiaDeviceInfo. Use the real observed numbers so the tests read as
the incident: 34_190_917_632 (5090) and 10_354_032_640 (3080).
-
index_fallback_does_not_borrow_memory_from_a_different_device
Given one visible GpuFacts (3080, index: 0, vram_bytes: 10_354_032_640, no usable pci_bdf)
and an infos list whose slot 0 is the 5090 (total_bytes: 34_190_917_632), assert after
enrich_nvidia_gpu_facts() that vram_bytes is still 10_354_032_640.
Fails today: becomes 34_190_917_632.
-
enrichment_declines_when_no_identity_matches
Same shape, asserting reserved_bytes, vendor_uuid, and stable_id are also left untouched, so
the fix does not simply special-case memory.
Fails today.
-
pci_bdf_match_wins_over_index_position
Visible 3080 at index: 0 with a valid pci_bdf, infos ordered so the 5090 sits at slot 0 and
the 3080 at slot 1. Assert the 3080's total_bytes is applied.
Should pass today (guards the correct path against regression while phases 2-3 change matching).
-
uuid_match_used_when_pci_bdf_is_a_placeholder
Placeholder pci_bdf plus a matching vendor_uuid. Assert identity match still succeeds.
Fails today: match_nvidia_device() never consults UUID.
-
single_visible_device_still_requires_identity_agreement
The existing infos.len() == 1 convenience path, but with the single info being a different
device. Assert no enrichment.
Fails today.
Phase 2 — RED: introduce a seam for the NVML merge, test-first
merge_nvml_device_infos() dlopens libnvidia-ml.so.1 inline, so it cannot be tested. Write the
failing test first:
nvml_merge_keys_by_identity_not_position
Given a CUDA list of length 1 (3080) and an NVML list of length 2 (5090 at 0, 3080 at 1), assert
the merged result attributes the 3080's memory to the 3080 and does not write the 5090's
memory into slot 0.
To make it compile, split the function:
fn nvml_device_infos() -> Vec<NvidiaDeviceInfo> // FFI only, untested
fn merge_device_infos(cuda: &mut Vec<NvidiaDeviceInfo>, // pure, unit-tested
nvml: &[NvidiaDeviceInfo])
fn merge_nvml_device_infos(infos: &mut Vec<NvidiaDeviceInfo>) {
let nvml = nvml_device_infos();
merge_device_infos(infos, &nvml);
}
This is a pure extraction with no behavior change, so tests 1-5 must stay at their existing red/green
status across it.
Phase 3 — GREEN: minimum change to pass
In dependency order:
merge_device_infos() joins on identity (uuid, else normalized pci_bdf). An NVML device with
no identity match is appended rather than merged into an unrelated slot.
match_nvidia_device() matches on normalized pci_bdf, then uuid, then returns None.
Delete the infos.get(gpu.index) fallback and the infos.len() == 1 shortcut, or gate the
shortcut on both lists having length 1 and identities agreeing.
enrich_nvidia_gpu_facts() keeps its existing behavior for matched devices; unmatched devices
retain skippy's vram_bytes.
- If phase 0 shows identity extraction is the real gap, fix
normalize_pci_bdf() and/or populate
vendor_uuid earlier, with its own red test first.
Emit a warn once per survey when a device cannot be identified and enrichment is skipped, so this
degrades loudly instead of silently. Add a test asserting the warning fires.
Phase 4 — Real two-GPU hardware validation (the actual proof)
Unit tests cannot prove this fix. Run on a host with two NVIDIA GPUs of different memory sizes, with
the packaged binary from a release bundle, not target/release.
For each setting, assert the reported gpus[] and my_vram_gb against libcuda ground truth:
CUDA_VISIBLE_DEVICES |
Expect visible |
Expect vram_bytes |
Expect my_vram_gb |
| unset |
both GPUs |
each device's own total |
budget over both |
0 |
5090 only |
5090's total |
5090-based |
1 |
3080 only |
3080's total (~10.35 GB) |
3080-based, not ~33.7 GB |
1,0 |
both, reordered |
each device's own total, correctly attributed |
budget over both |
0,1 |
both |
each device's own total |
budget over both |
Then confirm the downstream consequence is gone: with CUDA_VISIBLE_DEVICES=1, mesh-llm serve --model <~2.8 GB model> must load and serve without --max-vram, where today it dies with
cudaMalloc failed: out of memory. Capture /api/status, the model-load log, and nvidia-smi
before and after.
Finally, verify propagation: join a second node and confirm the peer entry's vram_gb matches the
corrected local value, since the wrong number was mesh-visible.
Phase 5 — Regression guard
- Keep tests 1-6 as the permanent guard; test 1 encodes the exact incident numbers.
- Add a CI test asserting
match_nvidia_device() has no positional fallback, for example by
constructing mismatched-length lists and asserting None. This is the invariant that broke.
- Consider a
doctor check that flags a mismatch between skippy's memory_total and the enriched
vram_bytes beyond a small tolerance, which would have surfaced this from a single command.
Acceptance criteria
Workaround until fixed
--max-vram <GB> caps advertisement, planning, and local-fit together, which corrects the advertised
figure and makes model load fit.
Found during an evidence-backed release validation of a12b535d7 against v0.76.0 on three real
hosts, including the A/B against the released build that established this as pre-existing.
Summary
When
CUDA_VISIBLE_DEVICESrestricts which GPUs are visible, a node reports the visible device'sname alongside a different device's memory, then advertises that wrong number to the mesh as its
placement budget.
Observed on a host with an RTX 5090 (device 0, 32607 MiB) and an RTX 3080 (device 1, 10240 MiB) with
CUDA_VISIBLE_DEVICES=1:libcuda under the identical environment sees only the 3080:
So the node advertises ~33.7 GB on a card holding ~10.35 GB, about 3.3x over-advertisement, and it
propagates: the peer entry on another node showed the same
vram_gb: 33.676066816.Not a regression. The released
v0.76.0binary reproduces the identical numbers, verified byrunning the published
mesh-llm-v0.76.0-x86_64-unknown-linux-gnu-cuda-13.tar.gz(checksum verified)against a locally built candidate on the same host with the same environment.
Root cause
Two positional joins between enumerations that do not share visibility semantics, all in
crates/mesh-llm-system/src/hardware/enrichers.rs(modulelinux):cuda_device_infos()enumerates via libcuda, which honoursCUDA_VISIBLE_DEVICES. With=1,infoshas one entry: the 3080.merge_nvml_device_infos()iterates NVML withfor index in 0..count. NVML ignoresCUDA_VISIBLE_DEVICES, socount == 2. It merges each NVML device intoinfos.get(index), soNVML device 0 (the 5090) is merged into the 3080's slot.
match_nvidia_device()tries a PCI-BDF match, then falls back toinfos.get(gpu.index). Theskippy-enumerated 3080 has
index == 0, so it matches the now-contaminated slot, andenrich_nvidia_gpu_facts()overwritesgpu.vram_byteswith the 5090's total.The GPU name is unaffected because it comes from the skippy backend-device enumeration, which is
why the two disagree and make the bug visible.
Two things worth noting for the fix:
gpu_facts_from_backend_devices()inskippy_devices.rssetsvram_bytes = device.memory_totalfrom skippy, which reflects the visibledevice. Enrichment then overwrites a correct value with a wrong one. Declining to enrich is
therefore a safe fallback, not a degradation.
pci_bdf = device.device_id, andis_placeholder_pci_bdf()exists, implying placeholders occur.Establishing why the identity match missed is part of the work below.
Consequence
vram_bytesis the mesh placement budget and context size auto-scales to it, so the inflated budgetproduced a real failure loading a 2.8 GB model:
Handled without a panic and the node stayed in the mesh. The
/v1/modelsunion correctly excludedthe failed model, so routing was not misled, but the peer still reported
serving_models: ["unsloth/Qwen3.5-4B-GGUF:UD-Q4_K_XL"]whilellama_ready: false.Scope
Any host where CUDA visibility is restricted and the visible device is not at the same global NVML
index: containers, Kubernetes with device plugins, and shared or scheduled multi-GPU machines. Those
are exactly the environments where a node is most likely to be handed a subset of the GPUs.
Introduced by
#509 (
Report backend GPUs and bundle GPU benchmarks, merged 2026-05-12, commit57cdac988), whichadded
enrichers.rs, thegpu.vram_bytes = total_bytesoverwrite, and theinfos.get(gpu.index)fallback.
git log -Splaces all three there.Fix plan (TDD)
enrichers.rscurrently has no test module and no test coverage. The plan below writes the testsfirst at every stage.
enrich_nvidia_gpu_facts()andmatch_nvidia_device()are already purefunctions taking
&[NvidiaDeviceInfo], so most of this needs no refactor to become testable;merge_nvml_device_infos()does need a seam.Real two-GPU hardware is required for phases 0 and 4. Unit tests cannot prove the fix, because the
whole bug lives in the disagreement between two real driver enumerations. They can only lock in the
logic once the hardware tells us what the real inputs look like.
Phase 0 — Characterize on real hardware (before writing any fix)
Goal: capture the real
GpuFactsand both driver enumerations on a two-GPU host, and answer the oneopen question: why did the PCI-BDF match miss?
On a host with two NVIDIA GPUs of different memory sizes (validated here on an RTX 5090 +
RTX 3080), for each of
CUDA_VISIBLE_DEVICESunset,0,1, and1,0, record:device_id/pci_bdfanddevice.memory_totalper device, pre-enrichmentcuda_device_infos()output: count, names,pci_bdf,total_bytespci_bdf,total_bytes/api/statusgpus[]nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id,uuidDeliverable: a short table in this issue showing, for each setting, whether identities matched and
which slot each device landed in. Do not proceed until it is clear whether the PCI-BDF miss is a
formatting mismatch, a placeholder id, or an absent id, because that decides whether the fix must
also repair identity extraction rather than only remove the positional fallback.
Phase 1 — RED: unit tests that fail on today's code (no refactor needed)
Create
mod testsinenrichers.rs, gated#[cfg(all(test, target_os = "linux"))], with a fixturebuilder for
GpuFactsandNvidiaDeviceInfo. Use the real observed numbers so the tests read asthe incident:
34_190_917_632(5090) and10_354_032_640(3080).index_fallback_does_not_borrow_memory_from_a_different_deviceGiven one visible
GpuFacts(3080,index: 0,vram_bytes: 10_354_032_640, no usablepci_bdf)and an
infoslist whose slot 0 is the 5090 (total_bytes: 34_190_917_632), assert afterenrich_nvidia_gpu_facts()thatvram_bytesis still10_354_032_640.Fails today: becomes
34_190_917_632.enrichment_declines_when_no_identity_matchesSame shape, asserting
reserved_bytes,vendor_uuid, andstable_idare also left untouched, sothe fix does not simply special-case memory.
Fails today.
pci_bdf_match_wins_over_index_positionVisible 3080 at
index: 0with a validpci_bdf,infosordered so the 5090 sits at slot 0 andthe 3080 at slot 1. Assert the 3080's
total_bytesis applied.Should pass today (guards the correct path against regression while phases 2-3 change matching).
uuid_match_used_when_pci_bdf_is_a_placeholderPlaceholder
pci_bdfplus a matchingvendor_uuid. Assert identity match still succeeds.Fails today:
match_nvidia_device()never consults UUID.single_visible_device_still_requires_identity_agreementThe existing
infos.len() == 1convenience path, but with the single info being a differentdevice. Assert no enrichment.
Fails today.
Phase 2 — RED: introduce a seam for the NVML merge, test-first
merge_nvml_device_infos()dlopenslibnvidia-ml.so.1inline, so it cannot be tested. Write thefailing test first:
nvml_merge_keys_by_identity_not_positionGiven a CUDA list of length 1 (3080) and an NVML list of length 2 (5090 at 0, 3080 at 1), assert
the merged result attributes the 3080's memory to the 3080 and does not write the 5090's
memory into slot 0.
To make it compile, split the function:
This is a pure extraction with no behavior change, so tests 1-5 must stay at their existing red/green
status across it.
Phase 3 — GREEN: minimum change to pass
In dependency order:
merge_device_infos()joins on identity (uuid, else normalizedpci_bdf). An NVML device withno identity match is appended rather than merged into an unrelated slot.
match_nvidia_device()matches on normalizedpci_bdf, thenuuid, then returnsNone.Delete the
infos.get(gpu.index)fallback and theinfos.len() == 1shortcut, or gate theshortcut on both lists having length 1 and identities agreeing.
enrich_nvidia_gpu_facts()keeps its existing behavior for matched devices; unmatched devicesretain skippy's
vram_bytes.normalize_pci_bdf()and/or populatevendor_uuidearlier, with its own red test first.Emit a
warnonce per survey when a device cannot be identified and enrichment is skipped, so thisdegrades loudly instead of silently. Add a test asserting the warning fires.
Phase 4 — Real two-GPU hardware validation (the actual proof)
Unit tests cannot prove this fix. Run on a host with two NVIDIA GPUs of different memory sizes, with
the packaged binary from a release bundle, not
target/release.For each setting, assert the reported
gpus[]andmy_vram_gbagainst libcuda ground truth:CUDA_VISIBLE_DEVICESvram_bytesmy_vram_gb011,00,1Then confirm the downstream consequence is gone: with
CUDA_VISIBLE_DEVICES=1,mesh-llm serve --model <~2.8 GB model>must load and serve without--max-vram, where today it dies withcudaMalloc failed: out of memory. Capture/api/status, the model-load log, andnvidia-smibefore and after.
Finally, verify propagation: join a second node and confirm the peer entry's
vram_gbmatches thecorrected local value, since the wrong number was mesh-visible.
Phase 5 — Regression guard
match_nvidia_device()has no positional fallback, for example byconstructing mismatched-length lists and asserting
None. This is the invariant that broke.doctorcheck that flags a mismatch between skippy'smemory_totaland the enrichedvram_bytesbeyond a small tolerance, which would have surfaced this from a single command.Acceptance criteria
CUDA_VISIBLE_DEVICES=1without--max-vramvram_gbmatches the corrected local valueWorkaround until fixed
--max-vram <GB>caps advertisement, planning, and local-fit together, which corrects the advertisedfigure and makes model load fit.
Found during an evidence-backed release validation of
a12b535d7againstv0.76.0on three realhosts, including the A/B against the released build that established this as pre-existing.