Skip to content

perf(lerobot_groot): run the whole vision tower in bf16 in bf16 builds - #2142

Merged
zhenshanx-nv merged 1 commit into
NVIDIA:mainfrom
zhenshanx-nv:zhenshanx/lerobot_groot-bf16-full-tower
Nov 10, 2026
Merged

zhenshanx-nv merged 1 commit into
NVIDIA:mainfrom
zhenshanx-nv:zhenshanx/lerobot_groot-bf16-full-tower

Conversation

@zhenshanx-nv

Copy link
Copy Markdown
Collaborator

Background

lerobot_groot bf16 bundles run the Qwen3-VL vision tower in bfloat16 since #2007, except the query/key/value projection of each of the 24 blocks, which stays float32. #2007 kept it because a fully bfloat16 tower moved one E2E observation, ep1267_f080, past its max-abs limit (6.2e-3 against 5.1e-3), and left the threshold decision open. The float32 projection costs 1.8 ms of a 12 ms policy step on B200.

This PR makes that decision with measured numbers. The bf16 limits were set at about twice LeRobot's own bfloat16 error on each observation (and three times for the "vs LeRobot bf16" limit): ep1262_f040 2 x 6.3e-3, ep1267_f080 2 x 2.55e-3, ep1272_f000 2 x 1.5e-2. On ep1267_f080, LeRobot bfloat16 is unusually close to float32: 2.55e-3, while its per-observation maximum error on 200 held-out observations has a median of 4.1e-3 and a 95th percentile of 1.0e-2. That makes 5.1e-3 the tightest limit of the three, and it is the only one a fully bfloat16 tower exceeds.

Exit Criteria

  • A bf16 build runs every matrix product of the vision tower in bfloat16, including the query/key/value projection. Norms, softmax, gelu, rotary and the residual stream stay float32, as in the rest of the graph; the measurement below shows that moving the residual stream to bfloat16 gives no speedup.
  • fp32 builds and the bf16 denoise network are unchanged: their network definitions are identical layer by layer to main. runtime.json, the bundle format and the runtime are unchanged.
  • Held-out action error against LeRobot float32 (200 observations) stays at or below LeRobot's own bfloat16 policy and within 1.1x of the current bf16 bundle, with no non-finite output.
  • Only the limits that the new bundle exceeds, or uses with no headroom, are changed. They are set from measured B200 and B300 values with headroom for GB300, and a broken build still fails them.
  • The family E2E passes through the real test_e2e.py path (native qualification binary) on B200 and B300.
  • Non-goals: quantization (FP8 for this family is not shipped, see the open docs PR docs(lerobot_groot): record why FP8 is not shipped #2118), a new build option, and changes to the fp32 bundle or to other parts of the bf16 graph.

Implementation

Affected: families/lerobot_groot only.

File Change
vision.py The attn.qkv projection uses the tower dtype instead of trt.float32, so all tower matrix products follow the build precision. Docstring updated; the unused tensorrt import is removed.
model.py Comment only.
tests/test_model.py New CPU test: build_vision passes the tower dtype to every projection, attention and merger product (bfloat16 and float32). The test fails on main for bfloat16.
tests/thresholds/groot-n17-libero-spatial-bf16-ep1267-f080.json max_abs_vs_fp32 0.0051 -> 0.0095, mean_abs_vs_fp32 0.00094 -> 0.0014. Cosine, max_abs_vs_bf16 and the latency limit are unchanged.
README.md Bundle table entry, the threshold rule, and the bf16 tower measurement table with the new numbers.

Changed thresholds (only ep1267_f080, bf16 manifest, non-premerge case):

Limit Old New Measured (B200 = B300 on 11.4; B200 on 11.1) Use of new limit Why
max_abs_vs_fp32 5.1e-3 9.5e-3 6.21e-3; 6.49e-3 65%; 68% The bundle exceeds the old limit. New = 1.46x the largest measured value. It stays below LeRobot bf16's own 95th-percentile per-observation maximum error on held-out data (1.04e-2). The broken build with scaled weights gives 1.58e-2 and still fails.
mean_abs_vs_fp32 9.4e-4 1.4e-3 9.34e-4; 8.65e-4 67%; 62% The bundle uses 99% of the old limit on TensorRT 11.4, which leaves no room for GB300 (a recent case failed on GB300 at a bar this tight). New = 1.50x the largest measured value. The broken build with scaled weights gives 1.81e-3 and still fails.

No other limit is changed. ep1272_f000 (the premerge case) uses at most 75% of a limit (mean vs fp32); ep1262_f040 uses at most 54%; max_abs_vs_bf16 uses at most 48% / 71% / 24%. On ep1272_f000, main itself uses 76% of the mean limit with the alternate text on TensorRT 11.1, so this case already varies this much between builds.

Change categories

  • Model or runtime behavior
  • Public API
  • ABI
  • Bundle or artifact format
  • Dependencies
  • Documentation only
  • CI or developer tooling

Validation

Commands and Results

Speed and quality use the plans model.build writes (precision="bf16", builder optimization level 3). A Python harness runs them the way the runtime does: prompt, image patches, state normalization, masks, four Euler steps, action unnormalization and the LIBERO gripper. It is the same harness as in #2007, and it gives the same fixture errors as the native E2E run below, digit for digit. "main" is built from the upstream/main tree in the same job. Policy step is the GPU time of one condition call plus four denoise calls (CUDA events, inputs on the device): the median of 20 interleaved rounds of 10 steps. All variants of one GPU type were built and timed in one job. Vision tower is the tower alone in its own engine (same build settings): the median of 20 rounds of 20 runs. "fp32" is the precision="fp32" bundle; it is the same on main and this branch.

Speed and size, TensorRT 11.4.0.106:

GPU Variant Policy step Speedup vs main bf16 Condition Denoise x4 Vision tower Bundle Condition engine layers Build
B200 fp32 bundle 47.01 ms 0.26x 21.72 ms 25.29 ms 10.37 ms (fp32 tower) 11.31 GB 887 108 s
B200 main bf16 12.08 ms 1.00x 5.86 ms 6.22 ms 3.55 ms 5.82 GB 727 209 s
B200 this PR 10.25 ms 1.18x 4.01 ms 6.24 ms 1.72 ms 5.67 GB 750 144 s
B300 fp32 bundle 45.04 ms 0.26x 20.75 ms 24.29 ms 9.31 ms (fp32 tower) 11.31 GB 887 116 s
B300 main bf16 11.64 ms 1.00x 5.53 ms 6.11 ms 3.26 ms 5.82 GB 727 213 s
B300 this PR 9.92 ms 1.17x 3.79 ms 6.13 ms 1.55 ms 5.67 GB 750 149 s

TensorRT 11.1.0.106 on B200: 12.14 -> 10.35 ms (1.17x), vision tower 3.58 -> 1.77 ms. The condition plan goes from 3809 to 3658 MB; the denoise plan is unchanged (286 engine layers, 2004 MB). Against the float32 tower that #2007 replaced (19.10 ms on B200 in #2007's job), the step is now 1.86x faster. In the native E2E below, the p95 wall time of one action chunk (qualification --repeat 20) goes from 14.4 to 12.6-12.8 ms on B200 and from 14.0 to 12.4 ms on B300.

Quality on 200 held-out LIBERO-spatial observations (50 episodes x 4 frames, not in the test fixtures). Each observation has fixed noise; actions are 16 x 7 in robot units. Arm = the 6 continuous dimensions; per-observation max = the largest arm error of one action chunk. B200 and B300 gave identical actions on TensorRT 11.4:

TensorRT Variant Arm mean vs LeRobot fp32 Ratio to main p99 Per-observation max: median / p95 / max Gripper flips Arm mean vs LeRobot bf16 Arm mean vs main engine Non-finite
11.4 main bf16 8.46e-4 1.00 5.77e-3 4.03e-3 / 1.07e-2 / 4.03e-2 0.03% 8.87e-4 0 / 204
11.4 this PR 9.11e-4 1.08 6.77e-3 4.25e-3 / 1.24e-2 / 4.53e-2 0.03% 9.39e-4 7.99e-4 0 / 204
11.4 fp32 bundle 3.65e-7 3.81e-6 1.2e-6 / 6.5e-6 / 3.3e-5 0 9.27e-4 0 / 204
11.1 main bf16 8.39e-4 1.00 5.59e-3 4.04e-3 / 1.19e-2 / 2.36e-2 0.09% 8.94e-4 0 / 204
11.1 this PR 9.02e-4 1.08 6.42e-3 4.06e-3 / 1.21e-2 / 4.62e-2 0.03% 9.40e-4 8.45e-4 0 / 204
LeRobot bf16 policy 9.27e-4 1.10 6.79e-3 4.06e-3 / 1.04e-2 / 6.09e-2 0.03%

The bundle stays below LeRobot's own bfloat16 error (9.11e-4 vs 9.27e-4). Its per-observation maximum error has the same median as LeRobot bfloat16 (4.25e-3 vs 4.06e-3) and a smaller worst case (4.5e-2 vs 6.1e-2).

E2E fixtures, max abs / mean abs vs LeRobot fp32, then max abs vs LeRobot bf16. Bold marks a miss of the old limit:

Run ep1262_f040 ep1267_f080 ep1272_f000 ep1272_f000 alternate text
main, 11.4 (B200 = B300) 3.64e-3 / 7.08e-4, 6.41e-3 3.27e-3 / 5.83e-4, 5.49e-3 7.38e-3 / 8.07e-4, 8.07e-3 5.05e-3 / 1.18e-3, 5.49e-3
this PR, 11.4 (B200 = B300) 4.76e-3 / 8.65e-4, 9.16e-3 6.21e-3 / 9.34e-4, 4.58e-3 1.77e-2 / 1.95e-3, 7.32e-3 1.07e-2 / 1.58e-3, 9.16e-3
main, 11.1 (B200, harness) 3.64e-3 / 5.03e-4, 5.49e-3 3.29e-3 / 5.98e-4, 5.49e-3 1.72e-2 / 1.85e-3, 7.32e-3 1.18e-2 / 1.97e-3, 1.30e-2
this PR, 11.1 (B200) 5.92e-3 / 9.65e-4, 9.16e-3 6.49e-3 / 8.65e-4, 5.49e-3 1.70e-2 / 1.91e-3, 6.18e-3 1.17e-2 / 1.74e-3, 1.10e-2
LeRobot bf16 policy 6.28e-3 / 9.20e-4 2.55e-3 / 4.70e-4 1.51e-2 / 1.29e-3 3.67e-3 / 9.45e-4
Limits (new in bold) 1.26e-2 / 1.8e-3, 1.9e-2 9.5e-3 / 1.4e-3, 7.7e-3 3.0e-2 / 2.6e-3, 4.5e-2 same as ep1272_f000

All cosines vs LeRobot fp32 are >= 0.99997 (limit 0.9999). On ep1272_f000, the 11.1 build of main already gives the same error as this PR (1.72e-2 / 1.85e-3). The premerge case therefore moves between builds by about this much, and its limits were not changed.

Broken builds against the new limits (harness on B200 and B300, TensorRT 11.4; the first one also through the native test_e2e.py):

Broken build ep1262_f040 ep1267_f080 ep1272_f000 (premerge) Held-out arm mean
Query/key/value bias of vision block 12 dropped fails (4.15e-2 / 4.89e-3) fails (6.06e-2 / 7.95e-3) fails (1.41e-1 / 1.58e-2) 7.5e-3
Every attention output projection weight x 1.02 fails (1.35e-2 / 1.86e-3) fails (1.58e-2 / 1.81e-3, 1.83e-2 vs bf16) passes (2.81e-2 / 2.44e-3, 94%) 2.1e-3
Block 0 matrix products in float16 instead of bfloat16 passes passes passes 9.2e-4

The float16 block is not a numerical defect: float16 has more mantissa bits than bfloat16, and its held-out error is the same as the bundle's. Native run of the dropped-bias build (B200, TensorRT 11.4): test_groot_bundle_matches_the_lerobot_reference_e2e fails on all three bf16 cases (for example, ep1267_f080 gives 6.06e-2 against 9.5e-3).

NaN scan: every variant in every job was checked: all condition outputs (state token, cross keys/values) and all denoise outputs (x_next, velocity) of the four steps, on the 200 held-out observations, the 3 fixtures and the alternate-text run. 0 non-finite.

Network checks (TensorRT 11.4, real checkpoint, network definitions compared layer by layer: layer type, output dtypes and shapes, constant weight hashes):

Network main this PR
fp32 condition 3854 layers, 5ef8c22f40b09cd4 identical
fp32 denoise 2625 layers, 2e1dbf8a95524bdf identical
bf16 denoise 3420 layers, c70eba2458019dcf identical
bf16 condition 4914 layers, f01e30cd0456361b 5010 layers, a964b3ee1e3cad76 (the tower's query/key/value product and the casts around it)

Native E2E through families/lerobot_groot/tests/test_e2e.py. The qualification and preprocess binaries were built from this branch with CMake (TRTMC_BUILD_TESTS=ON) in a CUDA 13.4 / TensorRT 11.4 container; the checkpoint is staged locally at the manifest revision:

Command GPU, TensorRT Result
python -m pytest families/lerobot_groot/tests/test_e2e.py --e2e-model lerobot_groot (bf16 and fp32 manifests, all 6 cases, premerge ep1272_f000 included) B200, 11.4 24 passed
same B300, 11.4 24 passed
... --e2e-model groot-n17-libero-spatial-bf16 B200, 11.1 12 passed
same, upstream/main tree with its own limits B200 and B300, 11.4 12 passed
same, this branch with the dropped-bias vision block B200, 11.4 3 failed (the three action checks), 9 passed

Tests and checks (B200, head of this branch):

Command Result
python -m pytest families/lerobot_groot/tests -q (TensorRT 11.4.0.106) 58 passed, 24 skipped (E2E without an explicit selector, as on main)
same with TensorRT 11.1.0.106 58 passed, 24 skipped
python -m pytest tools/tests/test_architecture.py -q 57 passed
python -m tools.model_ci validate valid
ruff check on the changed Python files all checks passed
ruff format --check on the changed Python files the 3 files would be reformatted, as on main (the family uses its own hand-wrapped style)

Alternatives considered

Same jobs as above (TensorRT 11.4, B200 and B300 identical in quality):

Variant Policy step B200 / B300 Vision tower B200 / B300 Held-out arm mean E2E fixtures with the old limits
main: query/key/value projection float32 12.08 / 11.64 ms 3.55 / 3.26 ms 8.46e-4 pass; ep1267_f080 max vs bf16 at 71%
this PR: all products bfloat16, residual stream float32 10.25 / 9.92 ms 1.72 / 1.55 ms 9.11e-4 ep1267_f080 max 6.21e-3 > 5.1e-3, mean at 99%
all products and the residual stream bfloat16 10.26 / 9.89 ms 1.71 / 1.58 ms 9.49e-4 pass; ep1267_f080 max 86%, mean 81%

Running the residual stream in bfloat16 too gives no speedup. Its held-out error is higher than LeRobot's own bfloat16 policy (9.49e-4 vs 9.27e-4; 1.02e-3 vs LeRobot bf16), and it passes the old ep1267_f080 limits with only 1.16x headroom. So the residual stream stays float32, as in the rest of the graph, and the limit is changed instead.

Hardware, Environment, and Revisions

B200 and B300 SXM6 (TensorRT 11.4.0.106 for speed, quality and the native E2E; TensorRT 11.1.0.106 on B200 for tests, quality and the native E2E), CUDA 13, torch 2.14.0. LeRobot 0.6.1 (lerobot[groot]) with transformers 5.5.4 for the float32 and bfloat16 references only. Checkpoint nvidia/gr00t17-lerobot-libero_spatial-640 at 32a6ec786d6509df31b40392b4e4dcdda78c0f11, base nvidia/GR00T-N1.7-3B at 2fc962b973bccdd5d8ce4f67cc63b264d6886495, tokenizer from Qwen/Qwen3-VL-2B-Instruct at 89644892e4d85e24eaac8bacfd4f463576704203, observations from physical-intelligence/libero at a4336d589d589045d1c56423ffdf3b88a0e19b1f. Repository base: d7ce64cd (upstream/main). Plans and bundles were built on node-local disk and deleted.

Not Run / Remaining Gaps

  • GB300: not measured. The two changed limits have 1.46x and 1.50x headroom over the largest B200/B300/11.1 value. B200 and B300 gave identical actions on TensorRT 11.4, and the 11.4 to 11.1 spread is about 1.05x on ep1267_f080.
  • RTX PRO 6000 Blackwell (the family's qualified GPU), H100 and Jetson: not measured. The README's RTX PRO 6000 bf16 column still shows the float32-tower numbers and says so.
  • No closed-loop LIBERO success rate (no simulator in this environment). Quality is action error against LeRobot float32 / bfloat16 on 200 recorded observations and the 3 fixtures.
  • The other lerobot-train outputs (libero_10, libero_goal) were not re-measured in bf16; their fp32 bundles are unchanged.
  • The open docs PR docs(lerobot_groot): record why FP8 is not shipped #2118 changes only families/lerobot_groot/README.md: it appends an FP8 study section after "Other lerobot-train outputs". This PR edits earlier sections of that file, and git merge-tree of the two branches merges cleanly. The bf16 step times quoted there (12.15 / 11.00 ms) are from before this PR.

Contributor Self-Review

  • I have completed a self-review of this change.

Notes For Future Readers

  • The bf16 limits follow one rule: about 2x LeRobot's own bfloat16 error on each observation (3x for max_abs_vs_bf16). ep1267_f080's max and mean vs fp32 are the exception. LeRobot bfloat16 happens to be very close to float32 there (2.55e-3, about half its held-out median), so those two limits come from the measured bundle error with about 1.5x headroom.
  • After this change, the vision tower is about 1.7 ms of a 10.3 ms step on B200. The four denoise steps (6.2 ms) and the backbone are now the largest parts.
  • Existing bf16 bundles keep working; rebuilding gives the faster condition engine. fp32 bundles are not affected.

Risk level

  • Low
  • Medium
  • High

Risk rationale: Low. One family, and one dtype changes in bf16 builds. fp32 networks and the bf16 denoise network are identical to main. Held-out error stays below LeRobot's own bfloat16 policy with no non-finite values. Two limits of one non-premerge E2E case are raised from measured values and still catch a broken build. The premerge case's limits are unchanged and it uses at most 75% of them.

🤖 Generated with Claude Code

The query/key/value projection of each vision block, the one part of the tower
kept in float32 by the bf16 tower change, now also runs in the build precision.
fp32 builds and the bf16 denoise network are unchanged.

The bf16 limits of the ep1267_f080 E2E case move from 5.1e-3 / 9.4e-4 to
9.5e-3 / 1.4e-3 (max / mean abs vs LeRobot fp32), set from the measured bundle
error with headroom; the other limits are unchanged.

Signed-off-by: Zhenshan Xie <zhenshanx@nvidia.com>
@coderabbitai

coderabbitai Bot commented Nov 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 12 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 92cfac5 and 0054b21.

Files selected for processing: 5

  • families/lerobot_groot/README.md
  • families/lerobot_groot/model.py
  • families/lerobot_groot/tests/test_model.py
  • families/lerobot_groot/tests/thresholds/groot-n17-libero-spatial-bf16-ep1267-f080.json
  • families/lerobot_groot/vision.py

@zhenshanx-nv zhenshanx-nv added the run-internal-ci Maintainer-approved dispatch to internal CI label Nov 10, 2026
@github-actions github-actions Bot removed the run-internal-ci Maintainer-approved dispatch to internal CI label Nov 10, 2026
@zhenshanx-nv
zhenshanx-nv merged commit f8800ce into NVIDIA:main Nov 10, 2026
35 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant