Inference optimization for MolmoBot VLA: torch.compile + CUDA graphs + action-expert KV cache - #10
Open
nguyenhoangthuan99 wants to merge 6 commits into
Open
Conversation
Add torch.compile and CUDA graph support to the inference wrapper
(SynthManipMolmoInferenceWrapper), plus benchmark and correctness tests.
Core changes (backward compatible — all new params default to no compile):
- inference_wrapper.py: New optional params compile_mode and
compile_cudagraphs. Move BufferCache to GPU before compile, pre-build
causal mask at max_seq_len, call warmup_cache at load time.
- video_olmo.py: Rewrite in-place x_flat[mask] += image_features as
functional masked_scatter + add (bitwise identical, unit-verified).
- torch_util.py: Add BufferCache.to(device) so cached tensors can be
moved to GPU explicitly (BufferCache is a plain dict, not registered
buffers).
New test/benchmark scripts:
- scripts/benchmark_inference.py — 3-way benchmark (baseline, compile,
compile+cudagraphs) using random dummy inputs. No sim or hardware
needed.
- scripts/test_correctness.py — masked_scatter bitwise identity,
replay stability (A-B-A cross-input test), eager vs compiled action
match within bf16 tolerance (<2% rel).
Results measured on RTX PRO 6000 (Blackwell), 30 warm queries:
Baseline (eager): 282 ms mean, +/-13.4 ms jitter, 351 ms max,
10.4 GB peak, 1.00x
COMPILE_MODEL=1: 181 ms mean, +/-11.8 ms jitter, 213 ms max,
10.9 GB peak, 1.56x
+COMPILE_CUDAGRAPHS=1: 166 ms mean, +/-4.6 ms jitter, 188 ms max,
10.3 GB peak, 1.70x
CUDA graphs give 3x tighter jitter (+/-4.6 vs +/-13.4 ms), which is
what a real-time control loop actually feels. Correctness verified:
compiled+cudagraph outputs match eager within kernel-fusion noise
(~0.7% rel), and replays are bit-stable even after interleaving
different-shaped inputs (no graph-pool memory corruption).
nguyenhoangthuan99
force-pushed
the
inference-optimization
branch
from
July 23, 2026 12:01
4a3a81a to
92d2e7e
Compare
During flow-matching action generation, MolmoBot runs the VLM backbone once and then integrates the action expert for num_steps Euler steps. On every step the action expert re-projects the full VLM context (the per-layer LLM hidden states, the robot state, and the attention mask) into cross-attention keys/values, even though those inputs are identical across all steps -- only the action-token queries and the flow timestep change. Compute the cross-attention K/V (and context projection, mask, encoded state) once per query and reuse them across every step. Per step the action expert then only projects the queries from the (few) action tokens. - ActionExpertAttention: add precompute_kv() and an optional kv_cache fast path in forward() (defaults to None -> original behavior, bitwise identical). - ActionExpertBlock.forward(): thread an optional cross_kv_cache into attn2. - ActionExpert: add inference-only prepare_cross_context() (computes the per-layer K/V + mask + encoded state once) and denoise_step() (light per-step forward that reuses the cache). forward() is left unchanged, so the training path is identical. - MolmoBot.generate_actions(): build the context cache once, then call denoise_step per flow step. The cache is a per-call local, so it stays a graph intermediate and remains compatible with torch.compile + CUDA graphs. Verified numerically identical to the previous path: prepare_cross_context + denoise_step matches ActionExpert.forward bitwise (fp32 and bf16, both cross_attn and self_attn states modes, with and without an attention mask), and the full compiled + CUDA-graph pipeline matches eager within bf16 kernel-fusion noise (<1% rel) with bit-stable replays. generate_actions latency (min over warm runs, RTX PRO 6000, 10 flow steps): eager 245.6 -> 227.9 ms (1.08x) torch.compile 159.8 -> 135.1 ms (1.18x) compile + cudagraphs 148.5 -> 124.6 ms (1.19x)
The model packs inputs to the actual sequence length, which varies with the instruction (~1621-1634 tokens: ~1600 fixed image tokens + a few instruction tokens). The whole-graph torch.compile + CUDA-graph capture therefore recompiled ~365s for EVERY distinct instruction length, making compile a net loss for varied-instruction eval sweeps. Add `compile_dynamic`: when set (with compile_model), mark ONLY the LLM sequence dim dynamic via torch._dynamo.mark_dynamic in get_action_chunk, so one compiled + cudagraphed graph serves every instruction length. A global dynamic=True can't be used because the action trajectory is built with torch.randn(..., generator=), which fails to trace once the action dims become symbolic. Measured (step21800, RTX PRO 6000): after one compile, all instruction lengths run at steady cudagraph speed (~140-150 ms full query) with only occasional short recompiles as the dynamic range widens, vs ~365 s recompile per length before. Steady-state numerics are unchanged (mark_dynamic only affects specialization). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… inference
compile_dynamic (marking the seq dim dynamic) removes the ~365s per-instruction
recompile but can still fire occasional short recompiles mid-run as the dynamic
range widens -- a mid-episode stall that's unacceptable for eval sweeps / control
loops.
compile_pad_len pads every request to a FIXED seq_len (via the collator's "to_max"
path + a bucket-sized preprocessor), so the shape is constant and the
compile+cudagraphs graph is captured EXACTLY ONCE -- zero recompiles afterward,
for any instruction. Padded positions are masked out (attention_mask feeds both
the LLM attention bias and the action-expert cross-attention mask), so outputs are
numerically identical to the unpadded call. Takes precedence over compile_dynamic.
Verified (step21800, RTX PRO 6000, full get_action_chunk):
- correctness: padded vs unpadded actions match to |Δ|=0.0033 (bf16 noise)
- robustness: 0/12 recompiles across varied instructions after warmup, at both
pad_len 1792 (~144ms) and 2048 (~164ms); vs ~365s per new length unpadded
- the fixed shape must be >= the largest real seq_len (~1600 image tokens +
instruction); the collator raises if an image token would be truncated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation) With compile_pad_len the collator truncates any request longer than the bucket. An over-long image would raise, but a long instruction tail is dropped silently (no error, no recompile). Log a warning so a clipped prompt is visible. Never fires for normal short instructions (~1600 image tokens << bucket). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add per-tensor dynamic fp8 (e4m3) for the transformer.blocks Linears (att_proj/attn_out/ ff_proj/ff_out), applied BEFORE torch.compile so inductor fuses the per-call activation quant into the fp8 _scaled_mm. Measured on the dense Qwen3-4B (sm_120, RTX PRO 6000): ~1.16x on generate_actions (144->124ms) and -3.6GB weights (10.2->6.5GB), at 0.4% max action-chunk delta. ViT / action-expert / embeddings stay bf16. Gated by the new quantize='fp8' arg (default None = bf16); only pays off with compile_model=1 (eager fp8 is slower). torchao's per-tensor path does NOT fuse on sm_120 (net ~1.04x), and rowwise fp8 is unsupported there -- hence the small hand-rolled fused linear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Inference optimization for MolmoBot VLA:
torch.compile+ CUDA graphs, and an action-expert KV cacheHi team! 👋
This PR bundles complementary, backward-compatible inference speedups for
SynthManipMolmoInferenceWrapper/ the flow-matching action expert:torch.compile+ CUDA graphs — CUDA-graph capture was being silently skipped due to three model-level issues; this fixes all three.COMPILE_PAD_LEN) — fixed-length padding so the compiled graph is captured once and never recompiles per instruction length.quantize='fp8') — per-tensor dynamic fp8 on the dense LLM blocks: ~1.16× and −3.6 GB weights on sm_120, at 0.4 % action delta.All are opt-in-safe (every new param defaults to the previous behavior) and come with benchmark + correctness tests.
Changes overview
olmo/models/molmobot/inference_wrapper.pycompile_mode,compile_cudagraphs,compile_pad_len,compile_dynamic,quantize. Pre-moveBufferCacheto GPU, pre-build causal mask,warmup_cacheat load. Embedded_Fp8DynamicLinear+ fp8 conversion (Part 3).olmo/models/video_olmo/video_olmo.pyx_flat[mask] += image_features→ functionalmasked_scatter+ add (bitwise identical, unit-verified)olmo/torch_util.pyBufferCache.to(device)—BufferCacheis a plaindict, not registered buffers, somodel.to()doesn't move itolmo/nn/action_expert.pyprecompute_kv()+ optionalkv_cachefast path in attention; inference-onlyprepare_cross_context()/denoise_step().forward()untouched.olmo/models/molmobot/molmobot.pygenerate_actions()builds the cross-attention context once, then callsdenoise_step()per flow stepscripts/benchmark_inference.pyscripts/test_correctness.pyAll new parameters default to the previous behavior. No existing caller is affected, and the training path is unchanged.
Part 1 —
torch.compile+ CUDA graphs + fixed-length paddingWhat was blocking CUDA graphs (and how I fixed it)
CPU-resident cache tensors —
BufferCacheis a plaindict, not registerednn.Buffers.model.to(device)never movedimage_tokens; every forward paid a CPU→GPU copy and inductor skipped graph capture.→
BufferCache.to(device)+ explicit call at load.Lazily-built cache tensors —
casual_maskand RoPE sin/cos were created inside the first forward and stored in the cache. Under cudagraphs they'd be backed by graph-pool memory that later replays overwrite.→ Pre-build causal mask at
max_seq_len; callwarmup_cachebefore compile.In-place input mutation —
x_flat[mask] += image_featuresmakesxan input of the resumed subgraph; inductor refuses to capture graphs that mutate inputs.→
masked_scatterinto zeros + add (provably bitwise identical, unit-tested).Length-robustness (
COMPILE_PAD_LEN)The model packs inputs to the actual
seq_len, which varies per instruction (~1621–1634 = ~1600 fixed image tokens + a few instruction tokens). A whole-graph capture therefore recompiles ~365 s per distinct length — a net loss for varied-instruction eval sweeps.COMPILE_PAD_LEN=N(default 2048) pads every request to a fixedseq_lenvia the collator'sto_maxpath, so the graph is captured exactly once and never recompiles — no mid-episode stalls. Padded positions are masked out, so actions are numerically identical to the unpadded call (worst |Δ| = 0.0033, bf16 noise).COMPILE_DYNAMIC=1is a riskier fallback (marks the seq dim dynamic; can still recompile mid-run). All tables below useCOMPILE_PAD_LEN=2048.Measured results — full-query latency + GPU memory
Hardware: RTX PRO 6000 (Blackwell, sm_120) • Checkpoint: MolmoBot VLA (dense Qwen3-4B LLM + SigLIP ViT + flow-matching action expert, 10 flow steps) • Method: 40 warm queries (cold discarded), each = 8 images (480×640) + 14-D state + instruction, at
COMPILE_PAD_LEN=2048. To isolate the compile/cudagraph/padding contribution the KV cache is held off here (Part 2 adds it). peak = max allocated after warmup; reserved = allocator reservation (incl. cudagraph pool).COMPILE_MODEL=1+COMPILE_CUDAGRAPHS=1COMPILE_PAD_LEN=2048. The ratio is lower than an unpadded run because fixed-length padding adds constant GEMM work to every config, diluting the launch-overhead win.mode="default"matches"max-autotune"without the long autotuning.Part 2 — Action-expert cross-attention KV cache
During flow-matching, the VLM backbone runs once and produces the per-layer LLM hidden states. The action expert is then integrated for
num_stepsEuler steps, and on every step it re-projected the full VLM context — the per-layer hidden states (≈1.6k tokens each), the robot state, and the attention mask — into cross-attention keys/values, across all layers. But those inputs are identical across steps; only the (few) action-token queries and the flow timestep change. So the large K/V projections were recomputednum_stepstimes over.Fix: compute the cross-attention K/V (plus context projection, mask, and encoded state) once per query and reuse them across every step. Per step, the action expert only projects the queries from the action tokens.
ActionExpertAttention:precompute_kv()+ optionalkv_cachefast path inforward()(defaults toNone→ original path, bitwise identical).ActionExpertBlock.forward(): threads an optionalcross_kv_cacheintoattn2.ActionExpert: inference-onlyprepare_cross_context()(per-layer K/V + mask + encoded state, once) anddenoise_step()(light per-step forward).forward()is left unchanged, so the training path is identical.MolmoBot.generate_actions(): builds the context cache once, then callsdenoise_step()per flow step. The cache is a per-call local, so it stays a graph intermediate and remains compatible withtorch.compile+ CUDA graphs.Measured results (action-model compute)
Hardware: RTX PRO 6000 (Blackwell) • same checkpoint, 10 flow steps.
Method: timing
generate_actionsonly (image preprocessing done once, outside the loop), min latency over warm runs, atCOMPILE_PAD_LEN=2048. KV-off is the faithful counterfactual on this branch — re-project the cross-context every flow step (the pre-cache behavior); KV-on is this branch. Everything else identical.torch.compilecompile + cudagraphscompile + cudagraphs(144.4 ms) vs KV cache off + eager (269.9 ms) = 1.87× on the action-model compute.num_steps(more steps reusing the same cached context) and with context length.Part 3 — Optional fp8 on the dense LLM blocks (
quantize='fp8')The served model is a dense Qwen3-4B (36
OLMoSequentialBlock; themoe_*config fields are inert). Its per-query cost, once compiled, is dominated by the LLM-backbone GEMMs.quantize='fp8'casts thetransformer.blocks.*Linears (fused-QKVatt_proj,attn_out, SwiGLUff_proj,ff_out) to per-tensor dynamic fp8 (e4m3), beforetorch.compileso inductor fuses the per-call activation quant (amax → cast) into the fp8_scaled_mm. ViT, the action-expert DiT, and embeddings stay bf16; RMSNorm / softmax / RoPE are nevernn.Linearso they stay bf16 automatically.Why a small hand-rolled linear rather than a library: on sm_120 (RTX PRO 6000 Blackwell, torch 2.7.1) rowwise fp8 scaling is unsupported (per-tensor only), and torchao's tensor-subclass path does not fuse the activation quant there — measured a net 1.04× (a loss after overhead). A plain-torch
amax → cast → _scaled_mmthattorch.compilefuses recovers 1.5–1.9× on the individual GEMMs.Measured results
Hardware: RTX PRO 6000 (Blackwell, sm_120) • Checkpoint: dense Qwen3-4B MolmoBot (5.0 B params, 10 flow steps) • Method:
generate_actionsonly, min latency over warm runs,COMPILE_PAD_LEN=2048. Memory viatorch.cuda: weights = allocated after load; peak = max allocated after warmup; reserved = allocator reservation (incl. cudagraph pool).generate_actionscompile + cudagraphs(bf16)+ quantize='fp8'compile_model=1(eager fp8 is slower — the fusion is the point). Stacks withCOMPILE_PAD_LEN.quantize=None).Combined — cumulative speedup vs original eager
All at
COMPILE_PAD_LEN=2048,generate_actionsmin latency, same dense Qwen3-4B checkpoint. Each row adds one optimization on top of the row above:generate_actionstorch.compile+ CUDA graphsquantize='fp8')~2.17× faster than eager on the action-model compute, plus −3.6 GB weights (10.2 → 6.5 GB from fp8). Padding (
COMPILE_PAD_LEN=2048) is held constant across the ladder — it's a serving-robustness feature (one compile, zero per-instruction recompiles), not itself a speedup. The compile + CUDA graphs + KV cache portion (1.87×) is numerically ~bit-exact and on by default; fp8 (the last 1.16× step) is opt-in and pending a sim success-rate A/B.Correctness
masked_scatter+ add matches originalx_flat[mask] += featsbitwise (fp32 + bf16)prepare_cross_context+denoise_stepmatchesActionExpert.forwardbitwise (fp32 + bf16,cross_attn&self_attn, ±attention mask, single-step and full loop)scripts/test_correctness.pyexercises the full pipeline, so with the KV cache in this branch it now validates the combined path (eager vs compiled+cudagraphs, replay stability) end-to-end.How to reproduce
Both scripts use random dummy inputs matching the checkpoint's obs contract (image resolution, camera count, state dim, instruction text). No simulation or hardware setup required. The KV-off vs KV-on numbers above were produced by running the same measurement against the parent (pre-cache) commit.