[MISC] Add lazy velocity update to kinematic entity. - #3251
Conversation
|
The key is to reduce the number of independent kernel calls, or at the very least, make sure that it does not increase. Otherwise, it is likely to cause performance regression instead of improvement. By the way, since this is a perf improvement PR, it would be nice to attach some benchmark stats to show the benefits of lazy velocity update when only position is requested (to show improvement), and when position+velocity is requested (to show no regression). |
|
Thanks. I reworked this around the number of independent host-side kernel launches. The measured steady-state counts are now:
set_dofs_position now fuses the DOF write, qpos synchronization, position FK, and COM propagation into one kinematic kernel. The kernel contains internally sequenced loops, but only one independent host-side launch. I also ran interleaved local Metal A/B benchmarks with 20k environments, but the machine could not be isolated from background load. The resulting 16% to 35% run-to-run ranges make the figures inconclusive, so I am not quoting a throughput number. The +12.43% figure in the earlier description was measured on the previous design and has been removed. I restored the anymal_uniform_kinematic benchmark removed in #3249 so the position-only workflow can be measured on reference hardware. |
@jeetrex17 We will need reliable numbers before merging. |
| ("anymal_kinematic_position", None, None, 20000, gs.gpu), | ||
| ("anymal_kinematic_position_velocity", None, None, 20000, gs.gpu), |
There was a problem hiding this comment.
We are not going to merge this, but it is fine for analysing the benefits of this PR temporarily.
I do not have regular access to CUDA hardware, so after the local Metal results were inconclusive, I was able to arrange temporary access to a workstation in my university lab and rerun the benchmarks on CUDA I ran five interleaved A/B pairs with the official benchmark runner on Ubuntu 24.04 with an RTX 2080 Ti and 20,000 environments. Each sample used a 45-second warmup and 15-second recording window. Throughput is measured in kinematic environment updates per second.
Every interleaved pair was positive in both scenarios. These results were collected with temporary focused fixtures for the two access patterns. Per your review, the fixtures have been removed from the final diff, so the benchmark suite is unchanged. The full methodology and results are in the PR description. |
| # Keep captured link poses consistent with the generalized coordinates stored alongside them | ||
| self.update_forward_pos() |
There was a problem hiding this comment.
This seems completely unrelated and could have unexpected side effects.
| @@ -736,8 +734,9 @@ def set_state(self, f, state, envs_idx=None, *, partial: bool = False) -> None: | |||
| ) | |||
| if not partial: | |||
| kernel_forward_kinematics(envs_idx, self.dyn_state, self.dyn_info, self.rigid_info, self.rigid_config) | |||
| self._is_forward_pos_updated = True | |||
| self._is_forward_vel_updated = True | |||
| # A subset refresh is globally fresh only if all untouched environments were already fresh | |||
| self._is_forward_pos_updated = is_all_envs or self._is_forward_pos_updated | |||
| self._is_forward_vel_updated = is_all_envs or self._is_forward_vel_updated | |||
There was a problem hiding this comment.
I don't like this kind of tricks. It is just a workaround. It seems that 'is_forward(pos|vel)_updated' should be refactored to be boolean masks.
| kernel_forward_kinematics(envs_idx, self.dyn_state, self.dyn_info, self.rigid_info, self.rigid_config) | ||
| self._is_forward_pos_updated = True | ||
| self._is_forward_vel_updated = True | ||
| self._is_forward_pos_updated = is_all_envs or self._is_forward_pos_updated | ||
| self._is_forward_vel_updated = is_all_envs or self._is_forward_vel_updated |
| kernel_forward_kinematics(envs_idx, self.dyn_state, self.dyn_info, self.rigid_info, self.rigid_config) | ||
| self._is_forward_pos_updated = True | ||
| self._is_forward_vel_updated = True | ||
| self._is_forward_pos_updated = is_all_envs or self._is_forward_pos_updated | ||
| self._is_forward_vel_updated = is_all_envs or self._is_forward_vel_updated |
| fn(envs_idx, self.dyn_state, self.dyn_info, self.rigid_info, self.rigid_config) | ||
| self._is_forward_pos_updated = True | ||
| self._is_forward_vel_updated = True | ||
| self._is_forward_pos_updated = is_all_envs or self._is_forward_pos_updated | ||
| self._is_forward_vel_updated = is_all_envs or self._is_forward_vel_updated |
| self.scene._envs_idx, | ||
| self.dyn_state, | ||
| self.dyn_info, | ||
| self.rigid_info, | ||
| self.rigid_config, | ||
| is_backward=False, |
There was a problem hiding this comment.
All these arguments fit on 1 line.
| gs_robot.set_dofs_velocity([0.3, -0.2]) | ||
| links_pos = gs_robot.get_links_pos().clone() | ||
| links_ang = gs_robot.get_links_ang().clone() | ||
| gs_robot.set_qpos([0.7, -0.4], skip_forward=True) | ||
| assert_allclose(gs_robot.get_links_ang(), links_ang, tol=tol) | ||
| assert_allclose(gs_robot.get_links_pos(), links_pos, tol=tol) | ||
|
|
||
| gs_robot.set_dofs_velocity([0.3, -0.2]) | ||
| deferred_links_pos = gs_robot.get_links_pos().clone() | ||
| deferred_links_vel = gs_robot.get_links_vel().clone() | ||
| deferred_links_ang = gs_robot.get_links_ang().clone() | ||
| gs_robot.set_qpos([0.7, -0.4]) | ||
| gs_robot.set_dofs_velocity([0.3, -0.2]) | ||
| assert_allclose(deferred_links_pos, gs_robot.get_links_pos(), tol=tol) | ||
| assert_allclose(deferred_links_vel, gs_robot.get_links_vel(), tol=tol) | ||
| assert_allclose(deferred_links_ang, gs_robot.get_links_ang(), tol=tol) |
There was a problem hiding this comment.
Add a code comment to explain what this is checking. Moreover, we should only assert what is part of the specification of genesis. From what I see, it is not immediately clear whether this is spec or side-effect.
| links_pos = gs_robot.get_links_pos().clone() | ||
| links_ang = gs_robot.get_links_ang().clone() |
There was a problem hiding this comment.
Clone is useless. All genesis getters are returning by value.
| deferred_links_pos = gs_robot.get_links_pos().clone() | ||
| deferred_links_vel = gs_robot.get_links_vel().clone() | ||
| deferred_links_ang = gs_robot.get_links_ang().clone() |
There was a problem hiding this comment.
Clone is useless. All genesis getters are returning by value.
| ghost_robot.set_dofs_position( | ||
| 0.1, | ||
| dofs_idx_local=-1, | ||
| envs_idx=torch.tensor((False, True), dtype=torch.bool, device=gs.device), |
There was a problem hiding this comment.
(False, True) is not supported? In any case, specifying 'dtype=torch.bool' is useless.
| DOFS_VELOCITY = torch.tensor( | ||
| ( | ||
| (0.0, 0.0, 0.0, 0.3, -0.2, 0.4, 0.5), | ||
| (0.0, 0.0, 0.0, -0.4, 0.2, 0.3, -0.6), | ||
| ), | ||
| dtype=gs.tc_float, | ||
| device=gs.device, | ||
| ) |
There was a problem hiding this comment.
Keep as a native python nested tuple.
| QPOS_QUAT = gu.xyz_to_quat( | ||
| torch.tensor(((90.0, 0.0, 0.0), (0.0, 90.0, 0.0)), dtype=gs.tc_float, device=gs.device), | ||
| degrees=True, | ||
| ) | ||
| QPOS = torch.cat( | ||
| ( | ||
| torch.tensor(((1.0, 2.0, 3.0), (1.0, 2.0, 3.0)), dtype=gs.tc_float, device=gs.device), | ||
| QPOS_QUAT, | ||
| torch.tensor(((0.1,), (-0.1,)), dtype=gs.tc_float, device=gs.device), | ||
| ), | ||
| dim=-1, | ||
| ) |
There was a problem hiding this comment.
This formatting is quite ugly and hard to read.
| DOFS_VELOCITY = torch.tensor( | ||
| ( | ||
| (0.0, 0.0, 0.0, 0.3, -0.2, 0.4, 0.5), | ||
| (0.0, 0.0, 0.0, -0.4, 0.2, 0.3, -0.6), | ||
| ), | ||
| dtype=gs.tc_float, | ||
| device=gs.device, | ||
| ) | ||
| QPOS_QUAT = gu.xyz_to_quat( | ||
| torch.tensor(((90.0, 0.0, 0.0), (0.0, 90.0, 0.0)), dtype=gs.tc_float, device=gs.device), | ||
| degrees=True, | ||
| ) | ||
| QPOS = torch.cat( | ||
| ( | ||
| torch.tensor(((1.0, 2.0, 3.0), (1.0, 2.0, 3.0)), dtype=gs.tc_float, device=gs.device), | ||
| QPOS_QUAT, | ||
| torch.tensor(((0.1,), (-0.1,)), dtype=gs.tc_float, device=gs.device), | ||
| ), | ||
| dim=-1, | ||
| ) |
There was a problem hiding this comment.
Move test constant on top the of unit test.
| ) | ||
|
|
||
| ghost_robot.set_qpos(QPOS, skip_forward=True) | ||
| kinematic_state = next(s for s in scene.get_state().solvers_state if isinstance(s, KinematicSolverState)) |
There was a problem hiding this comment.
just do scene.kinematic_solver
| ghost_robot.set_qpos(QPOS) | ||
| link_slice = slice(ghost_robot.link_start, ghost_robot.link_end) | ||
| assert_allclose(kinematic_state.links_pos[:, link_slice], ghost_robot.get_links_pos(relative=False), tol=tol) | ||
| assert_allclose(kinematic_state.links_quat[:, link_slice], ghost_robot.get_links_quat(relative=False), tol=tol) | ||
|
|
||
| # Link velocities are derived from the pose, so a deferred update must return what an eager one would | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| ghost_robot.set_quat( | ||
| gu.xyz_to_quat(torch.tensor((0.0, 90.0, 0.0), dtype=gs.tc_float, device=gs.device), degrees=True) | ||
| ) | ||
| links_vel = ghost_robot.get_links_vel() | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| assert_allclose(links_vel, ghost_robot.get_links_vel(), tol=tol) | ||
|
|
||
| qpos_deferred = QPOS.flip(0) | ||
| ghost_robot.set_qpos(QPOS) | ||
| ghost_robot.set_qpos(qpos_deferred, skip_forward=True) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| deferred_links_vel = ghost_robot.get_links_vel() | ||
| deferred_links_ang = ghost_robot.get_links_ang() | ||
| ghost_robot.set_qpos(qpos_deferred) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| assert_allclose(deferred_links_vel, ghost_robot.get_links_vel(), tol=tol) | ||
| assert_allclose(deferred_links_ang, ghost_robot.get_links_ang(), tol=tol) | ||
|
|
||
| ENVS_MASK = torch.tensor((True, False), dtype=torch.bool, device=gs.device) | ||
| qpos_env_1 = ghost_robot.get_qpos(envs_idx=[1]) | ||
| dofs_velocity_env_1 = ghost_robot.get_dofs_velocity(envs_idx=[1]) | ||
| links_vel_env_1 = ghost_robot.get_links_vel(envs_idx=[1]) | ||
| ghost_robot.set_qpos(QPOS[1:], envs_idx=ENVS_MASK) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY[1:], envs_idx=ENVS_MASK) | ||
| assert_allclose(ghost_robot.get_qpos(envs_idx=[0]), QPOS[1:], tol=tol) | ||
| assert_equal(ghost_robot.get_dofs_velocity(envs_idx=[0]), DOFS_VELOCITY[1:]) | ||
| assert_equal(ghost_robot.get_qpos(envs_idx=[1]), qpos_env_1) | ||
| assert_equal(ghost_robot.get_dofs_velocity(envs_idx=[1]), dofs_velocity_env_1) | ||
| assert_allclose(ghost_robot.get_links_vel(envs_idx=[1]), links_vel_env_1, tol=tol) | ||
| links_ang = ghost_robot.get_links_ang() | ||
| ghost_robot.set_dofs_velocity(ghost_robot.get_dofs_velocity()) | ||
| assert_allclose(links_ang, ghost_robot.get_links_ang(), tol=tol) | ||
|
|
||
| ghost_robot.set_quat( | ||
| gu.xyz_to_quat(torch.tensor((0.0, 0.0, 90.0), dtype=gs.tc_float, device=gs.device), degrees=True), | ||
| envs_idx=[1], | ||
| ) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY[0], envs_idx=[0]) | ||
| links_vel = ghost_robot.get_links_vel() | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| assert_allclose(links_vel, ghost_robot.get_links_vel(), tol=tol) | ||
|
|
||
| deferred_velocity = -DOFS_VELOCITY | ||
| ghost_robot.set_dofs_velocity(deferred_velocity, skip_forward=True) | ||
| scene.step() | ||
| links_ang = ghost_robot.get_links_ang() | ||
| ghost_robot.set_dofs_velocity(deferred_velocity) | ||
| assert_allclose(links_ang, ghost_robot.get_links_ang(), tol=tol) | ||
|
|
||
| state = scene.get_state() | ||
| links_vel = ghost_robot.get_links_vel(envs_idx=[1]) | ||
| links_ang = ghost_robot.get_links_ang(envs_idx=[1]) | ||
| ghost_robot.set_qpos(QPOS[0], envs_idx=[1]) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY[0], envs_idx=[1]) | ||
| scene.reset(state, envs_idx=[1]) | ||
| assert_allclose(ghost_robot.get_links_vel(envs_idx=[1]), links_vel, tol=tol) | ||
| assert_allclose(ghost_robot.get_links_ang(envs_idx=[1]), links_ang, tol=tol) |
There was a problem hiding this comment.
I'm skeptical so many checks are necessary to assert what you want.
| ghost.set_dofs_velocity(DOFS_VELOCITY) | ||
| ghost.set_dofs_position(ghost.get_dofs_position() + 0.05) | ||
| links_velocity = getter() | ||
| assert links_velocity.shape == expected_shape |
There was a problem hiding this comment.
We do not assert shape. it is trial. We are implemented integration tests, not unit tests in practice. They are here to catch real bugs. if something is always valid by design or the likelihood to cause any other test to fail indirectly is high, when the assertion is skipped, to reduce the CI cost and improve the signal-to-noise ratio.
| DOFS_VELOCITY = torch.linspace(-0.2, 0.2, ghost.n_dofs, dtype=gs.tc_float, device=gs.device) | ||
| expected_shape = (ghost.n_links, 3) if n_envs == 0 else (n_envs, ghost.n_links, 3) | ||
| for getter in (ghost.get_links_vel, ghost.get_links_ang): | ||
| ghost.set_dofs_velocity(DOFS_VELOCITY) | ||
| ghost.set_dofs_position(ghost.get_dofs_position() + 0.05) | ||
| links_velocity = getter() | ||
| assert links_velocity.shape == expected_shape | ||
| ghost.set_dofs_velocity(DOFS_VELOCITY) | ||
| assert_allclose(links_velocity, getter(), tol=tol) |
There was a problem hiding this comment.
What is this supposed to check?
| qpos_deferred = QPOS.flip(0) | ||
| ghost_robot.set_qpos(QPOS) | ||
| ghost_robot.set_qpos(qpos_deferred, skip_forward=True) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| deferred_links_vel = ghost_robot.get_links_vel() | ||
| deferred_links_ang = ghost_robot.get_links_ang() | ||
| ghost_robot.set_qpos(qpos_deferred) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| assert_allclose(deferred_links_vel, ghost_robot.get_links_vel(), tol=tol) | ||
| assert_allclose(deferred_links_ang, ghost_robot.get_links_ang(), tol=tol) | ||
|
|
||
| ENVS_MASK = torch.tensor((True, False), dtype=torch.bool, device=gs.device) | ||
| qpos_env_1 = ghost_robot.get_qpos(envs_idx=[1]) | ||
| dofs_velocity_env_1 = ghost_robot.get_dofs_velocity(envs_idx=[1]) | ||
| links_vel_env_1 = ghost_robot.get_links_vel(envs_idx=[1]) | ||
| ghost_robot.set_qpos(QPOS[1:], envs_idx=ENVS_MASK) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY[1:], envs_idx=ENVS_MASK) | ||
| assert_allclose(ghost_robot.get_qpos(envs_idx=[0]), QPOS[1:], tol=tol) | ||
| assert_equal(ghost_robot.get_dofs_velocity(envs_idx=[0]), DOFS_VELOCITY[1:]) | ||
| assert_equal(ghost_robot.get_qpos(envs_idx=[1]), qpos_env_1) | ||
| assert_equal(ghost_robot.get_dofs_velocity(envs_idx=[1]), dofs_velocity_env_1) | ||
| assert_allclose(ghost_robot.get_links_vel(envs_idx=[1]), links_vel_env_1, tol=tol) | ||
| links_ang = ghost_robot.get_links_ang() | ||
| ghost_robot.set_dofs_velocity(ghost_robot.get_dofs_velocity()) | ||
| assert_allclose(links_ang, ghost_robot.get_links_ang(), tol=tol) | ||
|
|
||
| ghost_robot.set_quat( | ||
| gu.xyz_to_quat(torch.tensor((0.0, 0.0, 90.0), dtype=gs.tc_float, device=gs.device), degrees=True), | ||
| envs_idx=[1], | ||
| ) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY[0], envs_idx=[0]) | ||
| links_vel = ghost_robot.get_links_vel() | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY) | ||
| assert_allclose(links_vel, ghost_robot.get_links_vel(), tol=tol) | ||
|
|
||
| deferred_velocity = -DOFS_VELOCITY | ||
| ghost_robot.set_dofs_velocity(deferred_velocity, skip_forward=True) | ||
| scene.step() | ||
| links_ang = ghost_robot.get_links_ang() | ||
| ghost_robot.set_dofs_velocity(deferred_velocity) | ||
| assert_allclose(links_ang, ghost_robot.get_links_ang(), tol=tol) | ||
|
|
||
| state = scene.get_state() | ||
| links_vel = ghost_robot.get_links_vel(envs_idx=[1]) | ||
| links_ang = ghost_robot.get_links_ang(envs_idx=[1]) | ||
| ghost_robot.set_qpos(QPOS[0], envs_idx=[1]) | ||
| ghost_robot.set_dofs_velocity(DOFS_VELOCITY[0], envs_idx=[1]) | ||
| scene.reset(state, envs_idx=[1]) | ||
| assert_allclose(ghost_robot.get_links_vel(envs_idx=[1]), links_vel, tol=tol) | ||
| assert_allclose(ghost_robot.get_links_ang(envs_idx=[1]), links_ang, tol=tol) |
There was a problem hiding this comment.
Add 1-line comment to each block
|
Thanks for the detailed review. I’ve addressed the latest comments, simplified the tests, added coverage for the masked paths, and clarified the skip_forward behavior. The targeted link-velocity test passes. |
| kernel_set_dofs_zero_velocity_with_freshness( | ||
| dofs_idx, envs_idx, self._is_forward_vel_updated, self.dyn_state, self.rigid_config | ||
| ) |
There was a problem hiding this comment.
It turns out setting 'self._is_forward_vel_updated' in python-scope using torch is faster than passing torch tensors directly to a quadrants kernels... Sorry about that. It should probably be mentioned in CODING_GUIDELINES.md.
There was a problem hiding this comment.
I’ll move the freshness-mask updates currently performed inside Quadrants kernels to Python.
There was a problem hiding this comment.
Now that I'm think about it, these masks should be used to skip update INSIDE the kernels for the envs that are already up-to-date. This means that this mask as to be passed one way or the other...
I'm afraid getting this PR right is harder than it seems if we want to address all the issues and edge-cases without compromising performance.
| self._is_forward_pos_updated: torch.Tensor | bool = False | ||
| self._is_forward_vel_updated: torch.Tensor | bool = False | ||
| # Host summaries avoid synchronizing the per-environment device masks on every getter | ||
| self._is_forward_pos_updated_for_all_envs = False | ||
| self._is_forward_vel_updated_for_all_envs = False |
There was a problem hiding this comment.
Why are both needed? I understand you point about GPY sync issue, but this is only if you need to do something like if self._is_forward_vel_updated_for_all_envs.all():. But if a mask is used everywhere, this should no longer be necessary, removing the need for 'all_envs' variant.
There was a problem hiding this comment.
The host summaries are only a fast path for fully fresh batches. Without them, getters would either need a synchronizing .all() check or launch a no-op refresh kernel even when every environment is already fresh, which would lose the 2 to 1 launch reduction on the position-only path. The per-environment masks are still needed for mixed batches, so the two serve different purposes.
There was a problem hiding this comment.
Sure. But computing '_is_forward_vel_updated_for_all_envs' requires a GPU sync anyway? it is only free when the user does not specify any envs_idx mask. This means that this shortcut only every take effect when envs_idx is not specify. I don't think it is a good idea, as per-env operations are becoming more and more common, and per-env stepping is going to be available soon.
In what extend this does affect performance? If updating position-only, you can still skip velocity update by default, which was the first objective of this PR.
There was a problem hiding this comment.
You are right that the position-only setter keeps its one-launch improvement without the host summaries. My earlier statement about losing that reduction was inaccurate.
I measured the current PR against a local variant with both host summaries removed on an RTX 2080 Ti, using 20k environments, five interleaved A/B pairs, a 45-second warmup, and a 15-second recording window:
| Workflow | Current PR | Masks only | Change |
|---|---|---|---|
Position + scene.step() |
32.47M | 27.78M | -14.42% (95% CI -16.17% to -12.67%) |
| Repeated fresh velocity read | 348.55M | 151.20M | -56.62% (95% CI -57.53% to -55.71%) |
| Position only | 33.87M | 34.00M | +0.39% (95% CI -1.04% to +1.81%) |
| Position + velocity read | 24.72M | 24.63M | -0.38% (95% CI -1.39% to +0.63%) |
The first two regressions occurred in all five pairs. The latter two differences are statistically inconclusive.
The host summaries are not computed from the device masks with .all(). They are plain Python booleans maintained conservatively from the call scope: a known full-batch refresh can set them to True, while any subset operation sets them to False. The per-environment masks remain the source of truth and are still passed into the kernels to skip fresh environments.
Without the summaries, substep_post_coupling and already-fresh getters launch a no-op refresh kernel. Under sustained per-environment operations, the summaries simply remain False and the code behaves like the mask-only variant until a full-batch refresh re-arms them.
Based on these results, I think we should keep both: the masks for per-environment correctness and skipping, and the host booleans only as a full-batch launch gate.
Description
Defers link-velocity propagation for kinematic entities until it is needed.
set_dofs_positionfuses the degree-of-freedom write, qpos synchronization, position forward kinematics, and center-of-mass propagation into one kinematic kernel.get_links_vel,get_links_ang, andset_dofs_velocityrefresh velocities when needed. A velocity write following a skipped pose update refreshes pose and velocity together so the resulting link velocities remain consistent. Other pose setters retain their eager full refresh.Measured independent host-side kernel launches per steady-state step:
The fused kernel contains internally sequenced top-level loops; the reduction is in independent host-side launches.
Two consistency fixes are included:
get_staterefreshes link poses before capture, keeping them consistent with the generalized coordinates stored alongside them.RigidSolver.get_links_angremains a direct read because rigid stepping owns velocity propagation.Related Issue
Supersedes #2558.
Motivation and Context
Kinematic entities are commonly driven by pose setters while many consumers never read link velocities. The position-only path therefore paid for unnecessary propagation. Fusing the position write with position forward kinematics reduces that path from two independent launches to one without increasing the position-plus-velocity path.
Benchmarks were run on Ubuntu 24.04 with an RTX 2080 Ti at 20k environments, using a 45-second warmup and 15-second recording window across five interleaved A/B pairs.
Every interleaved pair was positive in both scenarios. These results were collected with temporary focused fixtures for the two access patterns above. The fixtures were removed from the final diff after review because they were only needed to analyze this PR. The previous mixed fixture measured +0.67% because unrelated rigid-scene work diluted the optimized kinematic path.
Getter-triggered velocity refreshes run over the full batch. Only
envs_idx=Noneis treated as a definitely complete batch for freshness tracking because inspecting an arbitrary device mask would require synchronization.How Has This Been / Can This Be Tested?
The full kinematics file passes locally: 19 passed and 2 pre-existing macOS skips on an Apple M-series CPU with Python 3.13. The stale-pose velocity regression fails on the previous head with a maximum absolute link-velocity difference of 0.62 and passes after the fix. Deterministic instrumentation confirms one launch for position-only workflows and two for position-plus-velocity workflows, including the boolean-mask setter path.
Checklist:
Submitting Code Changessection of CONTRIBUTING document.