perf(act): training on 8x B200, 28.0 ms to 13.0 ms per step, mostly from the eager model's kernel launches - #4607
TarzanZhao wants to merge 11 commits into
Conversation
d8047f0 to
1b5bc1d
Compare
|
Ready for review. This PR speeds up the ACT training step in lerobot-train under DDP through six changes (compile, fused optimizer, and related fixes): 31.5 ms to 13.2 ms per step on 8 B200 GPUs, 2.4x. cc @HaomingSong (author of the distributed and accelerator modules) @pkooij (most recent changes to lerobot_train.py) @imstevenpmwork (merged the last changes to optimizers.py and modeling_act.py) Could a maintainer also approve the first-time contributor workflow runs so CI can start? Thanks. |
There was a problem hiding this comment.
🟡 Changes recommended
Compilation currently breaks parameter and checkpoint names, bypasses sharded validation in auto mode, and leaves existing tests failing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Optimizes ACT training throughput, especially for multi-GPU CUDA workloads.
Changes:
- Adds regional
torch.compilesupport and device-side loss metrics. - Tunes DDP, AdamW, and NUMA affinity for lower overhead.
- Integrates the optimizations into
lerobot-train.
File summaries
| File | Description |
|---|---|
src/lerobot/utils/logging_utils.py |
Converts scalar tensor metrics during logging. |
src/lerobot/scripts/lerobot_train.py |
Integrates compilation, NUMA binding, and DDP tuning. |
src/lerobot/policies/act/modeling_act.py |
Declares compile regions and avoids early synchronization. |
src/lerobot/optim/optimizers.py |
Enables fused CUDA AdamW. |
src/lerobot/distributed/utils.py |
Adds compilation, broadcast, and NUMA helpers. |
src/lerobot/distributed/__init__.py |
Exports new distributed utilities. |
src/lerobot/configs/train.py |
Permits compilation for non-sharded training. |
src/lerobot/configs/accelerator.py |
Updates DDP and compilation defaults. |
Review details
Suppressed comments (2)
src/lerobot/configs/train.py:358
- The existing
tests/configs/test_train_config_distributed.py::test_compile_placeholderstill expectsenabled=Trueon a non-sharded config to raise, so this change makes that test fail. Update it to cover the new contract: single-process/DDP is accepted, while explicit and auto-enabled sharded compilation are rejected.
if self.accelerator.compile.enabled and self.parallelism.is_sharded:
raise ValueError("--accelerator.compile is wired for DDP/single-process runs only.")
src/lerobot/configs/accelerator.py:189
- This claim contradicts the measured behavior in the PR: compiled ACT consumes dropout RNG in a different order, and same-seed losses exceed the stated tolerance.
fallback_randomuses eager RNG operators but does not preserve eager draw ordering, so document that same-seed compiled and eager trajectories can differ.
# Keep eager RNG semantics inside compiled regions (dropout, the VAE's randn_like), so a
# compiled run reproduces the eager one to fp32 rounding at the same seed.
fallback_random: bool = True
- Files reviewed: 8/8 changed files
- Comments generated: 7
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if regions: | ||
| for name in regions: | ||
| setattr(policy, name, torch.compile(getattr(policy, name), **kwargs)) | ||
| logging.info("torch.compile applied to %s (%s)", ", ".join(regions), kwargs) | ||
| return policy | ||
| logging.info("torch.compile applied to the whole policy (%s)", kwargs) | ||
| return torch.compile(policy, **kwargs) |
| if not isinstance(params, dict): | ||
| params = list(params) # may be a generator; it is read twice below | ||
| if _all_cuda_float(params): |
| # Allreduce bucket size. With a compiled backward every gradient is ready at once, so | ||
| # 25 MB buckets only add one collective launch and one rank-sync per bucket; a bucket | ||
| # larger than the model gives one allreduce per step. | ||
| bucket_cap_mb: int = 1024 |
| if self.accelerator.compile.enabled and self.parallelism.is_sharded: | ||
| raise ValueError("--accelerator.compile is wired for DDP/single-process runs only.") |
| current = os.sched_getaffinity(0) | ||
| if not cpus or current <= cpus: | ||
| return False | ||
| os.sched_setaffinity(0, cpus & current or cpus) | ||
| logging.info("Pinned to NUMA node %d (%d CPUs) for %s", node, len(cpus & current or cpus), device) | ||
| return True |
| train_tracker.preprocessing_s = time.perf_counter() - preprocessing_start | ||
|
|
||
| train_tracker, _ = update_policy( | ||
| train_tracker, output_dict = update_policy( |
| if isinstance(value, torch.Tensor) and value.numel() == 1: | ||
| value = value.item() # a 0-d tensor a policy left on the device, read back here |
|
@TarzanZhao Thanks so much for your contribution; we will review it asap. |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AWNsZ45BMx5moJogT9xxT
…when no BatchNorm trains The coalesced buffer broadcast blocks the host on every rank at each forward start, so it is a per-step rank barrier plus a 286 KB broadcast for buffers that never change (ACT: FrozenBatchNorm2d, positional embeddings). exp0914 split of exp0908 commit 8cf80f7: the gradient_as_bucket_view default flip is a flag that already exists at the base (--accelerator.ddp.gradient_as_bucket_view=true) and moves to the tuned baseline; the default stays False here. The code part (broadcast_buffers field + disable_buffer_broadcast_if_static) is what this commit keeps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cdPcxsNZ5nE3qys9t14sj
…to when the policy declares regions) fallback_random=True keeps the eager RNG stream (dropout, VAE randn_like) so a compiled run reproduces eager to fp32 rounding at the same seed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AWNsZ45BMx5moJogT9xxT
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AWNsZ45BMx5moJogT9xxT
… after the optimizer step Removes the two host syncs (l1_loss.item(), mean_kld.item()) between forward and backward, so the host can enqueue the backward while the forward runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AWNsZ45BMx5moJogT9xxT
With a compiled backward every gradient is ready at once; 25 MB buckets only add a collective launch and a rank sync per bucket. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AWNsZ45BMx5moJogT9xxT
pyproject enables disallow_untyped_defs for lerobot.distributed.*, so the pre-commit mypy hook failed on the untyped `compile_cfg` parameter. Annotate it as CompileConfig and drop the extra blank line ruff-format flagged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uuiVQa3BLp3KwiKPfXb5U
1b5bc1d to
e5621af
Compare
Formatting only. The import line in src/lerobot/distributed/__init__.py exceeded the line length once the NUMA re-export was no longer on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cdPcxsNZ5nE3qys9t14sj
torch.compile replaces `policy.model` with a wrapper, so every parameter under it is renamed to `model._orig_mod.*`. ACT selects its backbone parameter group by the `model.backbone` prefix, so with the optimizer built after the compile the backbone group came out empty and `optimizer_lr_backbone` was silently ignored. The two defaults are equal (1e-5), so a default run is unaffected, but any run that sets a different backbone learning rate lost it without a warning. Building the optimizer first keeps the policy's own parameter names, and it still happens before `prepare()`, which is what accelerate's FSDP2 path requires. Tests cover apply_torch_compile's enable logic and regional wrapping, that the wrapper keeps the same parameter objects, that a group selected by name is lost once the wrapper is in place, and that the train script keeps the two calls in this order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_compile_placeholder` asserted that `--accelerator.compile` is always rejected, which this branch replaces: compile is wired for DDP and single-process runs and still rejected when the run is sharded. Two tests now cover both sides of that gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed a compiled run reproduces an eager one to fp32 rounding at the same seed. It does not. fallback_random only picks eager's RNG implementation over inductor's; fusion still moves the point at which each dropout mask is drawn, so with ACT's dropout on the two runs part at step 1. The class docstring still described compile as an unwired placeholder, which this branch changes. It now says what is wired and keeps the setup-order contract for the sharded case, which is still rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary / Motivation
When I trained ACT with
lerobot-trainunder 8-GPU DDP on B200s, a step took 28 ms, but the GPU was busy for only about 30 % of it. The eager step launched about 1,150 small kernels, every forward waited for a buffer broadcast across all ranks, the forward read two losses back to the host, and the backward sent its gradients in nine all-reduces that each waited for the slowest rank. This PR compiles the ACT model with CUDA graphs, sends the gradients in one all-reduce, drops the broadcast and the loss reads, and uses fused AdamW. On those 8 B200 GPUs the step goes from 28.0 ms to 13.0 ms (2.15x); I have not measured other GPUs, single-process training or other policies.--accelerator.compilewas a placeholder onmain; it now compiles the regions a policy declares (policy.modelfor ACT) withmode="reduce-overhead", so each step replays a captured graph instead of launching about 1,150 kernels. It is on by default for policies that declare regions, which today is only ACT, and--accelerator.compile.enabled=falseturns it off. Step 1 pays a one-time compile of 27 s with a warm inductor cache and 260 s cold.bucket_cap_mbfield onDDPConfigdefaults to 1024, so the compiled backward's gradients go out in one all-reduce instead of nine that each waited for the slowest rank.--accelerator.ddp.bucket_cap_mb=25restores torch's default.broadcast_buffersfield onDDPConfigis switched off afterprepare()when the policy has no BatchNorm layer; a policy with trainable BatchNorm keeps the broadcast.ACTPolicy.forwardcalled.item()onl1_lossandkld_loss, which held the host until the forward finished. It now returns them as detached 0-d tensors, andMetricsTrackerreads them back after the optimizer step, where the loss is read anyway.fused=Trueinstead of one update per parameter; any other parameter set keeps the old path. On this job it did not change the step time, because the per-parameter work was off the critical path.Speedup Result
Measured on 8 B200 GPUs.
2774d9bdPer commit, measured cumulatively in an earlier round against the unmodified
mainwithout the two setup items listed under Details (two runs per commit, same step-time definition):2774d9bd978b148573bb3232663535e8,26680519policy.model, reduce-overheadd37994a15cc82b53bucket_cap_mb=1024e5621af6,ae4af846bc51b2d0,710ae0f7,23a91bd2Correctness Verification
I trained the baseline and this branch with the same seed on the same 300 batches on all 8 ranks, and compared 1,248 recorded values per rank (9,984 per run): the loss,
l1_loss,kld_lossand gradient norm at every step, and at steps 1 to 3 the batch shapes, the action values and the mean magnitudes of the ACT outputs. The tolerances were set before the runs and checked against three runs of the unmodified code. The recording code is on theperf/act-ddp-compile-verifybranch of my fork, not in this PR.172 of the 9,984 checks fail the tolerances set before the runs, so numerical equivalence has not been shown. The 172 are 122 per-step loss values (up to 8.1 % against a 3 % + 0.01 limit) and 50 ACT output means at steps 1 to 3 (0.3 to 0.6 % against 0.1 %). The other 9,812 are within tolerance, including every gradient norm, batch shape and action value, and the all-rank loss at step 300 is 2.442 on the baseline and 2.452 on this branch in both runs of each.
I guess the failures come from the order in which the dropout masks are drawn. ACT applies dropout in every transformer layer and inside
nn.MultiheadAttention, and the compiled graph draws those masks at different points inside its fused regions, so the two runs part at step 1 and follow different trajectories from there.l1_lossandkld_lossper rank, steps 1 to 5 / 6 to 300--policy.dropout=0, earlier run, not repeatedRelated issues
How was this tested (or how to run locally)
pre-commit run -a: all 19 hooks pass.pytest tests/scripts tests/distributed tests/configs/test_accelerator_config.py tests/processor/test_act_processor.py(CPU): 101 passed, 10 skipped.pytest tests/configs/test_train_config_distributed.py: 14 passed.test_compile_placeholderasserted that compile is always rejected; it is replaced by one test per side of the new gate, rejected when sharded and accepted otherwise.tests/distributed/test_policy_surface.pyforapply_torch_compile: the enable logic including the auto default, that regional compile wraps only the declared region, that the wrapper keeps the same parameter objects, that a parameter group selected by name is lost once the wrapper is in place, and that the train script builds the optimizer before compiling. The last one fails if the two calls are swapped back.MetricsTracker.update_metrics.Checklist (required before merge)
pre-commit run -a)pytest): 115 passed, 10 skipped on CPU over the files listed aboveReviewer notes
bucket_cap_mb=1024also applies to eager policies, where one bucket delays the all-reduce until the whole backward is done; fused AdamW and the broadcast skip have no config key. I can scopebucket_cap_mbto compiled runs if that is preferred.model.backboneprefix still matches andoptimizer_lr_backboneis no longer dropped when it differs fromoptimizer_lr. The measurements above predate that commit; the two learning rates are equal by default, so the grouping does not change any number in them.fallback_randomcomment claimed eager-equivalent numerics, and theCompileConfigdocstring still called compile an unwired placeholder._all_cuda_floatconsumes a generator passed as a group'sparams;compile.enabled=Nonebypasses the sharded check..item()change for the other eight policies whose forward reads losses back, and the launcher-level NUMA pinning andgradient_as_bucket_viewfindings in GPU busy about 30% of the time when training ACT on 8x B200 at the default settings #4612.Details: hardware, model, full command, traces
Hardware and environment. One node, 8 × NVIDIA B200 (sm_100, about 180 GB each), host CPUs on 2 NUMA nodes (GPUs 0 to 3 on node 0, 4 to 7 on node 1), driver 580.126.20, CUDA 13.0; node empty before every timed run. Python 3.12.14, torch 2.11.0+cu130 from PyPI (cuDNN 9.19, NCCL 2.28.9, triton 3.6.0), torchvision 0.26.0, torchcodec 0.11.1 with conda-forge ffmpeg 8.0.1, accelerate 1.14.0; lerobot
mainat2774d9bd,pip install -e ".[training]", nothing built from source. The dataset is copied to node-local disk before each run; the conda environment and the inductor cache are on NFS.Model and job. ACT as
configuration_act.pydefaults it: ResNet18 backbone with torchvision ImageNet weights; transformer width 512, 8 heads, feed-forward 3200 with ReLU, 4 encoder layers, 1 decoder layer; VAE encoder with 4 layers and latent size 32; chunk size 100; dropout 0.1;kl_weight10; 51 613 582 parameters, all trained. AdamW, lr 1e-5, weight decay 1e-4, gradient clipping at norm 10. fp32 withtorch.backends.cuda.matmul.allow_tf32 = Trueandcudnn.benchmark = True(the script's own settings,lerobot_train.py:438-439). 8-process DDP, 4 loader workers per process. Per process per step: 8 samples ofobservation.images.top(3×480×640 float32),observation.state(14) andaction(100×14) with anaction_is_padmask; 64 samples per step. Loss: L1 over the unpadded action steps plus 10 × the KL of the VAE latent to a standard normal.--seed=1000is the script's default and goes through itsset_seed; the two baseline runs reproduce each other's losses (78.861 to 2.442 / 2.442), as do the two branch runs (78.707 to 2.452 / 2.452).Measurement. Against the unmodified
2774d9bd, both versions with the same setup, which includes two items that are not part of this PR:--accelerator.ddp.gradient_as_bucket_view=true, an existing flag worth 2 ms on the eager step, and per-ranknumactl --cpunodebindto the NUMA node of the rank's GPU, worth 3 to 4 ms on the eager step. Without them the branch is 2.42x against the unmodified code. Step time isstep_sfrom the script's per-step log (the slowest rank, 1 ms resolution), median over steps 51 to 300; steps 1 to 50 are excluded because step 1 carries worker start, cuDNN autotune and, on the branch, the compile. Runs alternate between the versions on an otherwise empty node.Data (once):
lerobot/aloha_sim_insertion_human(50 episodes, 25 000 frames, 88 MB, v3 format) copied to node-local disk and passed as--dataset.root; ResNet18 weights underTORCH_HOME;HF_HUB_OFFLINE=1, nothing downloads.The measured job, both versions; only the checkout on
PYTHONPATHdiffers:numa_wrap.shis the per-rank programaccelerate launch --no_pythonstarts; it reads the rank's GPU fromLOCAL_RANK, looks up its NUMA node in sysfs, and execslerobot-trainundernumactl --cpunodebind(CPU affinity only; falls through unpinned when anything is missing):Rejected in the setup sweep on the unmodified code, within the 2 ms noise:
find_unused_parameters=false,static_graph=true,OMP_NUM_THREADS=8,NCCL_CUMEM_ENABLE=0(and step 1 +5 s),PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True. Accelerate's--dynamo_backend inductor --dynamo_mode reduce-overheadcompiled for 303 s and segfaulted on all 8 ranks at step 2. Rejected in an earlier round: inductor's default mode (no gain, 273 s compile), bf16 autocast for the ACT core (step unchanged, final loss +17 %),gradient_as_bucket_view=falseunder compile (worse). Not tried because they change the numerics or the job: mixed precision, batch size, step count,--policy.dropout=0,--cudnn_deterministic, gradient accumulation.Traces (torch.profiler, steps 60 to 62, all 8 ranks; open in https://ui.perfetto.dev; the README next to them lists code, script, hardware, env and capture window; the baseline trace is the unmodified
main):