Keep the CloudXR runtime on the GPU the XR frames are rendered on - #7381
Conversation
Greptile SummaryThe PR aligns CloudXR and Kit rendering on the selected CUDA device during XR sessions.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[XR launch with CUDA device] --> B[AppLauncher]
B --> C[Pin Kit renderer to CUDA index]
C --> D[Renderer activeCudaGpus setting]
D --> E[Teleop session lifecycle]
E --> F[Set CloudXR CUDA index]
F --> G[CloudXR compositor]
C --> H[Rendered XR swapchain]
H --> G
Reviews (2): Last reviewed commit: "Leave `--xr` without an explicit device ..." | Re-trigger Greptile |
There was a problem hiding this comment.
Isaac Lab Review Bot
The change aligns XR rendering and CloudXR runtime device selection by pinning the renderer under XR and deriving the runtime’s CUDA index from the resulting Kit setting.
- Design and architecture: The responsibility split is coherent: AppLauncher produces the renderer GPU setting, while the teleop lifecycle consumes that setting before launching CloudXR. The behavior is scoped to XR, preserving non-XR multi-GPU behavior.
- API: No public API or CLI surface changes. Existing renderer overrides and explicit CloudXR GPU selections remain authoritative, and both affected packages include changelog fragments.
- Implementation: The producer and consumer use the same CUDA index space and handle the trailing-comma Kit setting correctly. The process-global environment mutation is a tradeoff, but the candidate did not establish a concrete supported path where the renderer device changes between CloudXR sessions in one process or where the value must be restored.
No blocking issues. No inline issue met the actionable-evidence threshold; the assessment above records the review feedback.
Automated review; human maintainers own approval decisions.
| Left to itself the runtime takes the first Vulkan physical device. That | ||
| enumeration is unrelated to the CUDA ordering Isaac Lab selects the | ||
| simulation and renderer devices with, so on a multi-GPU host the | ||
| compositor routinely lands on a different card than the one holding the | ||
| rendered swapchain. Nothing reports an error -- the client connects, the | ||
| session starts and the encoder logs normal frame timings -- but the | ||
| headset only shows noise. |
There was a problem hiding this comment.
Unsure if we need the context, but the reason it picks the first Vulkan device is that most (all) windowed applications will pick the first Vulkan device and then ignore what the runtime asks the app to use, becuse the first Vulkan device is the GPU that the monitor is plugged into (breaks down in "unusual" setups).
| if any(name in os.environ for name in _CXR_GPU_INDEX_ENV_VARS): | ||
| return | ||
| if _env_file_pins_gpu_index(self._cloudxr_env_file): | ||
| return |
There was a problem hiding this comment.
Maybe add warnings here if they don't match, and ask the user to check the configs?
There was a problem hiding this comment.
One thing I realized is that this changes the behavior with --xr and --device cpu (which is the default for --xr). Previously, CXR was bound to whatever the first GPU was matching the vulkan behavior. Now, it is always bound to GPU 0 which may cause a mismatch if for example, the monitor is plugged into GPU 1.
Can we leave the --xr --device cpu behavior unchanged in this PR?
On a multi-GPU workstation an XR teleop session connects, starts, and streams
frames whose contents are garbage -- the headset shows noise. Nothing reports an
error: the client connects, `IsaacTeleop session started` is logged, and the
CloudXR encoder reports normal per-frame timings.
Two independent device selections drift apart:
1. `_ensure_cloudxr_runtime` constructs `CloudXRLauncher` without saying which
GPU to use, so the runtime falls back to automatic selection
(`gpuIndexVulkan: -1`) and takes the first Vulkan physical device. Vulkan's
enumeration is unrelated to the CUDA ordering Isaac Lab selects the
simulation and renderer devices with. On the host this was found on, Vulkan
index 0 is `nvidia-smi` GPU 2 and Vulkan index 1 is a llvmpipe software
device, so the compositor imported swapchain memory from a card that holds no
rendered frames.
2. `_resolve_kit_args` only applies `--/renderer/multiGpu/activeCudaGpus` when
`launcher_args["multi_gpu"] is False`, and that key is assigned in exactly
one place: the `distributed` branch of `_resolve_device_settings`. There is
no `--multi_gpu` or `--distributed` CLI argument, and `_sim_app_config` is
built by intersecting with the keys actually present, so a plain run never
sets it. `--device cuda:1` therefore put physics on GPU 1 while the renderer
kept `multiGpu/enabled = True` across every visible GPU -- which also makes
"the GPU the frames are rendered on" ill-defined for (1) to match against.
Probing carb settings after startup:
--device cuda:1 -> /physics/cudaDevice = 1
activeCudaGpus = None
--device cuda:1 --kit_args "...=1," -> /physics/cudaDevice = 1
activeCudaGpus = '1,'
The comment above `launcher_args["physics_gpu"]` already states that
"the renderer device is selected in `_resolve_kit_args`", which is what the
gate prevents outside distributed runs.
Pin the renderer to the simulation device whenever XR is enabled, and point the
CloudXR runtime at that same device through `NV_CXR_GPU_INDEX_CUDA`. The scope
is deliberately limited to XR, where a single stereo swapchain must be imported
by the compositor; non-XR single-process runs keep their current behaviour, so
the trade-off settled in isaac-sim#7057 is untouched. The runtime rejects setting both
index variables at once, so an index already present in the environment or in
the `--cloudxr_env` profile is left alone.
Verified end to end on a 4x RTX 5090 host with Meta Quest 3 over CloudXR.js,
Isaac Lab 3.0.0, `IsaacContrib-Stack-Cube-Franka-IK-Abs`, headless
(`--visualizer none --xr --device cuda:1`):
* before: headset shows noise; `gpuIndexVulkan: -1` and
`compositor_set_cuda_device_for_vk` lands on a different card than the renderer
* after, with no manual `--kit_args` and no GPU index in the profile:
`Pinned the CloudXR runtime to CUDA device 1`, `gpuIndexCuda: 1`, and the
compositor logs `Physical device 1 is being used by Vulkan` -- the same
physical device the renderer is on. The scene renders correctly and the robot
is teleoperable.
Signed-off-by: 2047767028-lang <2047767028@qq.com>
|
Good catch — thank you, that is a real regression and I had not covered it.
Pushed c6f7f90 to require a CUDA device before pinning: if launcher_args.get("multi_gpu") is False or (self._xr and "cuda" in self.device):The teleop side needs no change to match: with the renderer unpinned, Verified both paths by reading the carb settings back after startup:
So Happy to look at the bare- |
Review feedback: pinning the renderer whenever XR is enabled also changed the default `--xr` path. `_resolve_device_settings` resolves a bare `--xr` to `device = "cpu"`, leaving `device_id` at its `0` initialiser, so the renderer would have been pinned to CUDA 0 rather than left to Kit. On a host whose display is not attached to GPU 0 that is a regression: Kit previously followed the same auto-selection the CloudXR runtime used, and the two agreed. Require a CUDA device before pinning. `--device cuda:<n>` with `--xr` still aligns the renderer and the compositor, which is the case this PR set out to fix; a bare `--xr` keeps today's behaviour on both sides, since the teleop side also leaves the runtime alone when the renderer is unpinned and the simulation device is CPU. Signed-off-by: 2047767028-lang <2047767028@qq.com>
|
run-ci |
|
run-ci |
CI caught two failures in `test_app_launcher_argv.py`:
AttributeError: 'AppLauncher' object has no attribute 'device'
AttributeError: 'AppLauncher' object has no attribute '_xr'
Both tests build a partial launcher with `AppLauncher.__new__` and set only the
attributes `_resolve_kit_args` needed before this PR. Gating the renderer pin on
XR added two more reads, so the spectator-view tests now set `device` (and
`_xr`, which one of them never set) alongside the state they already stub.
Also covers the new behaviour directly. `_resolve_devices_and_kit_args` takes an
`xr` flag, and `test_xr_pins_the_renderer_only_for_a_cuda_device` asserts both
halves of the gate: `--xr --device cuda:1` emits
`--/renderer/multiGpu/activeCudaGpus=1,` with `physics_gpu = 1`, while a bare
`--xr` resolves to CPU and emits nothing, which is the case raised in review.
Reverting the `app_launcher.py` change fails the first parameter set, so the
test pins the behaviour rather than passing vacuously.
17 passed locally, up from 15 with 2 failing.
Signed-off-by: 2047767028-lang <2047767028@qq.com>
|
Thanks for merging
Both build a partial launcher with I also added direct coverage rather than only unbreaking the existing tests:
raised from |
|
run-ci |
|
Backported to |
) # Description On a multi-GPU workstation an XR teleop session connects, starts, and streams frames whose contents are garbage — the headset shows noise. Nothing reports an error: the client connects, `IsaacTeleop session started` is logged, and the CloudXR encoder reports normal per-frame timings (`GpuEndToEncodeEnd 7.1ms`), which makes it look like a client or network problem rather than a device-selection one. Two independent device selections drift apart. **1. The CloudXR runtime picks its own GPU.** `_ensure_cloudxr_runtime` constructs `CloudXRLauncher` without saying which device to use, so the runtime falls back to automatic selection (`gpuIndexVulkan: -1` in `cxr_server.log`) and takes the first Vulkan physical device. Vulkan's enumeration is unrelated to the CUDA ordering Isaac Lab selects the simulation and renderer devices with. On the host this was found on: | Vulkan index | `nvidia-smi` index | | --- | --- | | 0 | 2 | | 1 | *llvmpipe (software rasterizer)* | | 2 | 0 | | 3 | 1 | So the compositor imported swapchain memory from a card that holds no rendered frames. **2. The renderer is not pinned either, so there is no single "render GPU" to match.** `_resolve_kit_args` applies `--/renderer/multiGpu/activeCudaGpus` only when `launcher_args["multi_gpu"] is False`, and that key is assigned in exactly one place: the `distributed` branch of `_resolve_device_settings`. There is no `--multi_gpu` or `--distributed` CLI argument, and `_sim_app_config` is built by intersecting with the keys actually present, so a plain run never sets it. Probing carb settings after startup: | Command | `/physics/cudaDevice` | `activeCudaGpus` | `multiGpu/enabled` | | --- | --- | --- | --- | | `--device cuda:1` | `1` | **`None`** | `True` | | `--device cuda:1 --kit_args "--/renderer/multiGpu/activeCudaGpus=1,"` | `1` | `'1,'` | `True` | After the change, the same probe confirms the narrowed scope: | Command | `activeCudaGpus` | `/physics/cudaDevice` | | --- | --- | --- | | `--xr` (resolves to `cpu`) | `None` — unchanged from `develop` | `0` | | `--xr --device cuda:1` | `'1,'` | `1` | Physics lands on GPU 1 while the renderer spans every visible GPU — visible in the log as `Usdrt Hydra CUDA Peer Memory Copies from device[0] to device[2] is NOT possible as peer access is disabled`, and as an OOM abort when one of those cards is busy. The comment above `launcher_args["physics_gpu"]` already states that *"the renderer device is selected in `_resolve_kit_args`"*, which is what the gate prevents outside distributed runs. ## The change Pin the renderer to the simulation device when XR is enabled **and a CUDA device has been selected**, and point the CloudXR runtime at that same device through `NV_CXR_GPU_INDEX_CUDA`. A bare `--xr` resolves to `device = "cpu"` in `_resolve_device_settings`, and is deliberately left alone: there is no simulation GPU to align to, and forcing CUDA 0 would break hosts whose display is not on GPU 0 (Kit's auto-selection and CloudXR's Vulkan auto-selection agree there today). With the renderer unpinned the teleop side also stands down, so that path is unchanged on both ends. The scope is deliberately limited to XR, where a single stereo swapchain has to be imported by the compositor. Non-XR single-process runs keep their current behaviour, so the trade-off settled in #7057 is untouched — that PR's concern was distributed ranks under a `CUDA_VISIBLE_DEVICES` mask, and its gate stays exactly as it is for those runs. The runtime rejects setting both index variables at once (`Only one of gpu-index-vulkan and gpu-index-cuda may be set at a time`), so an index already present in the process environment or in the `--cloudxr_env` profile is left alone. The CUDA index is used rather than the Vulkan one because it is the same index space `--device cuda:N` already speaks, so no Vulkan enumeration has to be parsed. ## Validation End to end on a 4× RTX 5090 host with a Meta Quest 3 over CloudXR.js, Isaac Lab 3.0.0, `IsaacContrib-Stack-Cube-Franka-IK-Abs`, headless (`--visualizer none --xr --device cuda:1`): * **Before** — headset shows noise. `gpuIndexVulkan: -1`, and `compositor_set_cuda_device_for_vk` lands on a different physical device than the renderer. * **After**, with no manual `--kit_args` and no GPU index in the profile — `Pinned the CloudXR runtime to CUDA device 1`, `gpuIndexCuda: 1`, and the compositor logs `Physical device 1 is being used by Vulkan, selecting that device for use in CUDA`, the same physical device the renderer is on. The scene renders correctly and the robot is teleoperable. The `cxr_server.log` settings block is byte-identical between the patched run and a known-good run that had been pinned by hand, apart from the index being expressed as `gpuIndexCuda: 1` instead of `gpuIndexVulkan: 2` — both resolve to the same physical device, which the compositor confirms with the same `Physical device 1` line. Negotiated stream size is unchanged (`packed stream size: 4096x4032`). ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [x] <!-- backport-active-release --> Backport this pull request to the active release branch after it merges into `develop` ## Screenshots The failure is a headset-only artefact (a full-frame noise pattern) and the fix restores the normal scene; I do not have a capture path off the device that would show it faithfully. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there <sub>`test_xr_pins_the_renderer_only_for_a_cuda_device` covers the renderer half of the gate in CI. The CloudXR half needs a multi-GPU host whose Vulkan and CUDA orderings disagree plus a connected headset, so that part rests on the manual validation above. `CONTRIBUTORS.md` is untouched; let me know if you would like me to add an entry.</sub> --------- Signed-off-by: 2047767028-lang <2047767028@qq.com> Co-authored-by: Kelly Guo <kellyg@nvidia.com> (cherry picked from commit 3a31389)
isaac-sim#7381 added _env_file_pins_gpu_index/_renderer_cuda_index at the same spot in session_lifecycle.py where this branch adds cloudxr_eula_accepted. Both blocks are pure additions and are kept; nothing else conflicted. No behaviour change: test_cloudxr_lifecycle.py passes (33 tests) with ISAACLAB_CXR_ACCEPT_EULA unset, 1 and 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SsU8ziGULaEBbtmvJSQZW
Description
On a multi-GPU workstation an XR teleop session connects, starts, and streams frames whose
contents are garbage — the headset shows noise. Nothing reports an error: the client
connects,
IsaacTeleop session startedis logged, and the CloudXR encoder reports normalper-frame timings (
GpuEndToEncodeEnd 7.1ms), which makes it look like a client or networkproblem rather than a device-selection one.
Two independent device selections drift apart.
1. The CloudXR runtime picks its own GPU.
_ensure_cloudxr_runtimeconstructsCloudXRLauncherwithout saying which device to use, so the runtime falls back to automaticselection (
gpuIndexVulkan: -1incxr_server.log) and takes the first Vulkan physicaldevice. Vulkan's enumeration is unrelated to the CUDA ordering Isaac Lab selects the
simulation and renderer devices with. On the host this was found on:
nvidia-smiindexSo the compositor imported swapchain memory from a card that holds no rendered frames.
2. The renderer is not pinned either, so there is no single "render GPU" to match.
_resolve_kit_argsapplies--/renderer/multiGpu/activeCudaGpusonly whenlauncher_args["multi_gpu"] is False, and that key is assigned in exactly one place: thedistributedbranch of_resolve_device_settings. There is no--multi_gpuor--distributedCLI argument, and_sim_app_configis built by intersecting with the keysactually present, so a plain run never sets it. Probing carb settings after startup:
/physics/cudaDeviceactiveCudaGpusmultiGpu/enabled--device cuda:11NoneTrue--device cuda:1 --kit_args "--/renderer/multiGpu/activeCudaGpus=1,"1'1,'TrueAfter the change, the same probe confirms the narrowed scope:
activeCudaGpus/physics/cudaDevice--xr(resolves tocpu)None— unchanged fromdevelop0--xr --device cuda:1'1,'1Physics lands on GPU 1 while the renderer spans every visible GPU — visible in the log as
Usdrt Hydra CUDA Peer Memory Copies from device[0] to device[2] is NOT possible as peer access is disabled, and as an OOM abort when one of those cards is busy. The comment abovelauncher_args["physics_gpu"]already states that "the renderer device is selected in_resolve_kit_args", which is what the gate prevents outside distributed runs.The change
Pin the renderer to the simulation device when XR is enabled and a CUDA device has been
selected, and point the CloudXR runtime at that same device through
NV_CXR_GPU_INDEX_CUDA.A bare
--xrresolves todevice = "cpu"in_resolve_device_settings, and is deliberatelyleft alone: there is no simulation GPU to align to, and forcing CUDA 0 would break hosts whose
display is not on GPU 0 (Kit's auto-selection and CloudXR's Vulkan auto-selection agree there
today). With the renderer unpinned the teleop side also stands down, so that path is unchanged
on both ends.
The scope is deliberately limited to XR, where a single stereo swapchain has to be imported
by the compositor. Non-XR single-process runs keep their current behaviour, so the trade-off
settled in #7057 is untouched — that PR's concern was distributed ranks under a
CUDA_VISIBLE_DEVICESmask, and its gate stays exactly as it is for those runs.The runtime rejects setting both index variables at once
(
Only one of gpu-index-vulkan and gpu-index-cuda may be set at a time), so an index alreadypresent in the process environment or in the
--cloudxr_envprofile is left alone. The CUDAindex is used rather than the Vulkan one because it is the same index space
--device cuda:Nalready speaks, so no Vulkan enumeration has to be parsed.
Validation
End to end on a 4× RTX 5090 host with a Meta Quest 3 over CloudXR.js, Isaac Lab 3.0.0,
IsaacContrib-Stack-Cube-Franka-IK-Abs, headless (--visualizer none --xr --device cuda:1):gpuIndexVulkan: -1, andcompositor_set_cuda_device_for_vklands on a different physical device than the renderer.--kit_argsand no GPU index in the profile —Pinned the CloudXR runtime to CUDA device 1,gpuIndexCuda: 1, and the compositor logsPhysical device 1 is being used by Vulkan, selecting that device for use in CUDA, the samephysical device the renderer is on. The scene renders correctly and the robot is
teleoperable.
The
cxr_server.logsettings block is byte-identical between the patched run and aknown-good run that had been pinned by hand, apart from the index being expressed as
gpuIndexCuda: 1instead ofgpuIndexVulkan: 2— both resolve to the same physical device,which the compositor confirms with the same
Physical device 1line. Negotiated stream sizeis unchanged (
packed stream size: 4096x4032).Type of change
Release backport
developScreenshots
The failure is a headset-only artefact (a full-frame noise pattern) and the fix restores the
normal scene; I do not have a capture path off the device that would show it faithfully.
Checklist
pre-commitchecks with./isaaclab.sh --formatsource/<pkg>/changelog.d/for every touched package (do not editCHANGELOG.rstor bumpextension.toml— CI handles that)CONTRIBUTORS.mdor my name already exists theretest_xr_pins_the_renderer_only_for_a_cuda_devicecovers the renderer half of the gate inCI. The CloudXR half needs a multi-GPU host whose Vulkan and CUDA orderings disagree plus a
connected headset, so that part rests on the manual validation above.
CONTRIBUTORS.mdisuntouched; let me know if you would like me to add an entry.