
A 30 Hz control loop gives you 33.3 ms per tick. Where those milliseconds go on an SO-100, what action chunking really buys, and the measured point where the arm starts to hesitate.
A 30 Hz control loop gives you 33.3 milliseconds per tick. Inside that window a robot has to read its joint positions, pull a camera frame, turn both into an action and get that action onto the servo bus. Miss the window and nothing dramatic happens at first: the loop just takes longer, the commanded positions arrive late, and the arm develops a small stutter that you notice before you can name it. Miss it badly and the policy that looked fine in evaluation starts hesitating at exactly the moments that matter, like the instant before a grasp.
This is a budget article. It walks through where the milliseconds go in a real policy rollout on an SO-100, what action chunking actually buys you (it is not what most people assume), and the measured point at which running longer without a fresh observation starts costing success rate. Numbers about models come from papers, model cards and source code fetched and checked on 2026-08-24. Numbers about this platform come from the policy catalog.
The short version
- •At 30 Hz your whole tick budget is 33.3 ms. On this platform a single action step costs 20 ms (ACT) to 485 ms (Pi0.5), so every model except ACT is already over budget on its own.
- •Action chunking does not make inference faster. It makes inference less frequent, which stops the loop starving. It does nothing about how old the observation behind an action is.
- •Staleness, not starvation, is what makes an arm hesitate. The SmolVLA ablation on LIBERO shows average success falling from 82.8% (new observation every 10 steps) to 51.8% (every 50 steps).
- •LeRobot's own defaults are more open-loop than most people realise: ACT ships with n_action_steps = 100, which at 30 Hz is 3.3 seconds between looks at the world.
- •Real-Time Chunking and asynchronous inference are the two mechanisms that let a slow model drive a fast loop. Both ship in LeRobot today.
- •Public-internet round trips add to observation age on every single action. Remote inference is fine for slow pick-and-place and wrong for fast reactive motion.
What actually happens inside one tick
LeRobot's deployment entry point is lerobot-rollout. Its config defaults fps to 30.0, and the base rollout strategy wraps each part of the loop body in a named timing section. Those section names are the honest taxonomy of a control loop, so it is worth reading them straight from the source rather than inventing a model of your own.
# src/lerobot/rollout/strategies/base.py - the base strategy loop body
while not ctx.runtime.shutdown_event.is_set():
timer.tick(new_cycle=interpolator.needs_new_action())
with timer.section("observe"):
obs = robot.get_observation()
with timer.section("process_obs"):
obs_processed = self._process_observation_and_notify(ctx.processors, obs)
action_dict = send_next_action(obs_processed, obs, ctx, interpolator, timer)
# -> with section("infer"): engine.get_action(...)
# -> with section("send"): robot.send_action(...)
with timer.section("telemetry"):
self._log_telemetry(obs_processed, action_dict, ctx.runtime)
with timer.section("query"):
engine.pump_query(obs_processed)
timer.wait()| Stage | What it does | What sets its cost |
|---|---|---|
| observe | sync_read of Present_Position over the servo bus, then one frame per camera | Serial bus turnaround and camera frame period. LeRobot drives Feetech buses at DEFAULT_BAUDRATE = 1_000_000, and cameras run on a background thread whose async_read waits up to 200 ms by default. |
| process_obs | Resize, normalise, build the observation tensors | Image size and camera count. SmolVLA pads images to 512x512, and GR00T N1.7 timings are published for one camera. |
| infer | The policy forward pass, or a pop from a queue if a chunk is already in flight | Model size and denoising steps. This is the line item this article is mostly about. |
| send | sync_write of Goal_Position | One bus write. If max_relative_target is set, the SO follower does an extra sync_read of Present_Position first, so that safety flag costs a second bus round trip per tick. |
| telemetry / query | Rerun or Foxglove logging, optional text-head queries | Usually small, but display_data=true with uncompressed images is not free. |
| wait | precise_sleep to the tick deadline | Nothing, if you had budget left. Everything, if you did not. |
The pacing helper deserves one line of its own. precise_sleep has a 10 ms spin threshold and a 5 ms sleep margin, but read the body before you assume what it does: only on macOS and Windows does it sleep to 5 ms before the deadline and then spin out the last stretch. On Linux it falls straight through to time.sleep, which the source calls accurate enough for most uses. Its docstring says the defaults were chosen to prioritise timing accuracy over CPU usage for the common 30 FPS case. So the busy-wait trade is real, but only on two of the three platforms.
LeRobot's CycleTimer sums the time the loop body actually spends, excluding pacing sleeps, and compares that sum against the 1/fps budget. Time lost to the OS during a sleep sits deliberately outside the warning, because it is not something you can act on. The class docstring notes that with no tolerance at all the softer cadence note fired on 556 of 576 groups in a healthy 30 Hz run, which is a useful reminder that scheduler jitter is normal and not a bug you need to chase.
Inference is the biggest single line item
Here is the platform's own per-action-step latency for the five trainable policies, converted into tick budgets at 30 Hz. The conversion is plain arithmetic on the catalog numbers, nothing more.
| Policy | Params | Per action step | 33.3 ms ticks consumed | GPU tier |
|---|---|---|---|---|
| ACT | ~80 M | 20 ms | 0.6 | RTX 4090 or any 24 GB card |
| GR00T N1.7 | ~3 B | 152 ms | 4.6 | A100 80 GB or H100 80 GB |
| GR00T N1.5 | ~3 B | 165 ms | 5.0 | A100 80 GB or H100 80 GB |
| SmolVLA | ~450 M | 245 ms | 7.4 | RTX 4090 or any 24 GB card |
| Pi0.5 | ~3 B | 485 ms | 14.6 | A100 80 GB or H100 80 GB |
Only ACT fits inside a tick. Everything else is over budget by a factor of roughly five to fifteen, which is the entire reason the rest of this article exists. If you are choosing between them, the ACT against SmolVLA comparison and the GR00T N1.7 against Pi0.5 page put the same numbers next to accuracy and dataset requirements.

Where those milliseconds sit inside the model
NVIDIA publishes a per-stage breakdown on the GR00T N1.7 model card, measured with 4 denoising steps and one camera. It is the clearest public answer to the question of where a vision-language-action model spends its time. These rows were read from the card on 2026-08-24.
| Device | Mode | Data processing | Backbone | Action head | End to end | Frequency |
|---|---|---|---|---|---|---|
| H100 80GB HBM3 | PyTorch eager | 6.2 ms | 31.3 ms | 48.2 ms | 85.8 ms | 11.7 Hz |
| H100 80GB HBM3 | torch.compile | 6.2 ms | 30.4 ms | 12.0 ms | 48.6 ms | 20.6 Hz |
| H100 80GB HBM3 | TensorRT full pipeline | 6.2 ms | 8.8 ms | 12.3 ms | 27.9 ms | 35.9 Hz |
| L40 | PyTorch eager | 6.6 ms | 42.8 ms | 78.9 ms | 128.3 ms | 7.8 Hz |
| L40 | TensorRT full pipeline | 6.6 ms | 13.1 ms | 18.8 ms | 38.4 ms | 26.0 Hz |
| AGX Thor | PyTorch eager | 8.21 ms | 55.26 ms | 81.65 ms | 144.9 ms | 6.9 Hz |
| Orin | PyTorch eager | 9.45 ms | 127.6 ms | 205.39 ms | 342.8 ms | 2.9 Hz |
Three things fall out of that table. Data processing is noise, 6.2 to 9.5 ms across those rows. The backbone and the action head are roughly comparable in eager mode, and the action head is what compilation fixes: on an H100 it drops from 48.2 ms to 12.0 ms with torch.compile alone. And a full TensorRT pipeline is the difference between 11.7 Hz and 35.9 Hz on the same card, which is the difference between a model that cannot hold 30 Hz and one that can.
Flow-matching and diffusion heads let you trade quality for speed by changing the number of inference steps. GR00T N1.7's checkpoint default is 4 (num_inference_timesteps in LeRobot's GR00T config), while SmolVLA, Pi0 and Pi0.5 all default to 10. The OpenVLA-OFT paper measured the whole curve on an A100: 50 diffusion steps cost 1.9070 s per query at 91.1% LIBERO-Long success, 5 steps cost 0.2279 s at 90.0%, 2 steps cost 0.0996 s at 85.7%, and 1 step cost 0.0731 s at 0.0%. The dial is nearly free until it is not.
What action chunking actually buys you
Action chunking means the policy emits a sequence of future actions from a single observation instead of one action. It arrived with ACT in Learning Fine-Grained Bimanual Manipulation with Low-Cost Hardware (Zhao et al., 2023), where the arms ran at 50 Hz and the chunk size was fixed at k = 100. Every modern VLA does some version of it: Pi0 uses H = 50 and reaches control frequencies up to 50 Hz, and the Diffusion Policy paper (Chi et al., 2023) built receding-horizon control around the same idea.
The misunderstanding worth clearing up: chunking does not reduce inference latency by a single millisecond. A 485 ms forward pass still takes 485 ms. What chunking changes is how often you have to pay it. One call buys you N actions, so the loop can keep issuing commands at 30 Hz while the next call is still running. It solves starvation. It does not solve staleness.
| Policy (LeRobot main, 2026-08-24) | chunk_size | n_action_steps | Seconds of actions per call at 30 Hz | Denoising steps |
|---|---|---|---|---|
| ACT | 100 | 100 | 3.33 s | not applicable |
| SmolVLA | 50 | 50 | 1.67 s | 10 |
| Pi0 | 50 | 50 | 1.67 s | 10 |
| Pi0.5 | 50 | 50 | 1.67 s | 10 |
| GR00T | 40 | 40 | 1.33 s | 4 (checkpoint default) |
Read the fourth column again. With stock settings, an ACT policy running at 30 Hz looks at the world once, then acts blind for three and a third seconds. That is not a bug in LeRobot, it is what n_action_steps = chunk_size means. It is also almost certainly not what you want.
- A slow model can drive a fast loop. Pi0.5 at 485 ms per step still fills a 30 Hz command stream if one call covers 1.67 s of actions.
- Smoother motion. The chunk is a coherent trajectory the model planned as a unit, not a sequence of independently sampled actions.
- Fewer forward passes means less GPU time per episode, which shows up directly in cloud cost.
- It captures temporal dependencies in the demonstrations. The Bidirectional Decoding paper (Liu et al., 2024) frames this as the upside of the trade explicitly.
- Reduced reactivity to unexpected states. That is the exact phrasing in the Bidirectional Decoding abstract, and it is the whole problem.
- Discontinuities at chunk boundaries. The Real-Time Chunking paper describes pauses or out-of-distribution jerky movements where adjacent chunks jump between different strategies.
- Compounding error over the horizon. LeRobot's own async docs warn that larger actions_per_chunk can mean less precise actions from predicting over longer timespans.
- It hides the latency problem rather than fixing it. Your queue is full and your observation is old, and only one of those is visible in the logs.
The point where the arm starts to hesitate
This is the part with real measurements attached. The SmolVLA paper (Shukor et al., June 2025) ran an ablation on LIBERO that varies exactly one thing: how many actions from a 50-step chunk get executed before a new observation is taken. Everything else is held constant.
| Actions executed before re-observing | Spatial | Object | Goal | Long | Average success |
|---|---|---|---|---|---|
| 1 | 89 | 94 | 85 | 53 | 80.3% |
| 10 | 89 | 94 | 91 | 57 | 82.8% |
| 30 | 76 | 91 | 74 | 42 | 70.8% |
| 50 (the whole chunk) | 54 | 70 | 58 | 25 | 51.8% |
Executing the full chunk costs 31 percentage points of average success against re-observing every 10 steps. The paper's own summary is that sampling new observations more frequently significantly improves performance. A companion ablation on chunk size found sizes between 10 and 50 balance reactivity against efficiency, with size 1 collapsing to 50.0% and size 100 falling back to 74.5%.
Diffusion Policy reached the same conclusion from the other direction two years earlier. Its ablation found an action horizon of 8 steps optimal for most tasks tested, with the paper noting that too long a horizon reduces performance due to slow reaction time. It also defines latency in a way worth stealing: the number of steps between the last frame of observations and the first action that can be executed. That is the quantity your budget should track, not the forward-pass time in isolation.
You train an ACT policy, it evaluates well open-loop, and on the arm it lunges confidently at where the cube was. Nothing in the logs looks wrong: the loop holds 30 Hz, the queue never starves, no warning fires. The cause is n_action_steps = 100 in configuration_act.py, which is 3.3 seconds of open loop at 30 Hz. Set --policy.n_action_steps to something in the 8 to 16 range and re-run before you touch anything else. Note also that LeRobot ships temporal_ensemble_coeff = None, so ACT's temporal ensembling is off by default, and enabling it requires n_action_steps = 1. See policy freezes mid-motion and arm twitches then sags for the neighbouring failure modes.
Telling staleness apart from starvation
These two failure modes look different on the arm and they have different fixes, so it is worth being able to name them on sight. Starvation is the loop having nothing to send. The arm holds position, or twitches, and then jumps when the next chunk lands. LeRobot counts this directly: the run summary reports ticks with no action to send, labelled as the inference engine being starved, and notes that each of those ticks commanded nothing and recorded no frame. If that counter is above zero you have a throughput problem, and the fixes are mechanical: a bigger chunk, a lower fps, a faster model, or fewer denoising steps.
Staleness is different, and worse, because it does not show up in any counter. The loop holds its rate perfectly, the queue never empties, no warning fires, and the arm moves smoothly to the wrong place. What you see is confident motion aimed at where the object was a second ago, followed by a correction once a fresh observation finally arrives. Operators usually describe it as the arm looking hesitant or second-guessing itself, which is a fair description of a policy repeatedly discovering that its plan was built on old information. The only fix is to shorten the interval between observations, which means a smaller execution horizon, and that is a decision you have to make deliberately because nothing in the logs will make it for you.
Run the same policy twice: once at your normal execution horizon, once with the horizon set to 1 (one policy call per tick) and the fps lowered until the cadence report is clean. If behaviour improves at the slower rate, you had a staleness problem and chunking was hiding it. If it does not improve, the model itself is the limit and the loop was never the issue. It is a slow experiment and it saves a lot of guessing.
What the network adds, per action, forever
A round trip is not a one-off cost you pay at startup. It is added to the age of every observation the policy ever sees. The Real-Time Chunking paper (Black, Galliker and Levine, arXiv v2 revised December 2025) is unusually concrete about this. It notes that Pi0 running remote inference for mobile manipulation lists 13 ms of network latency in perfect conditions with a wired connection, and that in a more realistic setting the network overhead alone could easily exceed 20 ms. Their own real-world setup used remote inference over LAN, which added 10 to 20 ms.
For comparison, Hugging Face's async inference post (published 10 July 2025) reports sub-100 ms round-trip latency over gRPC on their local network with SmolVLA hosted on an RTX 4090. Both of those are LAN numbers. Neither is a public-internet number, and a public-internet number is not a fixed multiple of them, because the tail matters more than the mean. A loop with a 33 ms budget is wrecked by a 400 ms p99 even when the median is a comfortable 40 ms.
- 1Measure the round trip before you design around it
Run this against the host you plan to put the policy on, from the machine the arm is attached to. Look at the maximum and the standard deviation, not the average.
bash# 200 pings, one every 200 ms, so you see the tail and not just the mean ping -c 200 -i 0.2 your-gpu-host.example.com # where the variance comes from mtr --report --report-cycles 100 your-gpu-host.example.com - 2Convert it into ticks
At 30 Hz, divide by 33.3. A 45 ms round trip is 1.35 ticks of pure transport added to every observation, before the model has done anything at all.
bashpython3 -c "rtt=45; fps=30; print(f'{rtt/1000*fps:.2f} ticks of transport per action')" - 3Add it to the model number and compare against your chunk runway
With actions_per_chunk = 50 and a request fired when the queue is half empty, you have 25 actions of runway, which at 30 Hz is 833 ms. Inference plus round trip has to fit inside that or the queue starves.
bash# runway in ms = remaining_actions / fps * 1000 python3 -c "print(25/30*1000, 'ms of runway')" # 833.3 ms # Pi0.5 at 485 ms + 45 ms RTT = 530 ms. Fits, with 303 ms of margin.
Notice what that last step does and does not prove. It proves the queue will not run dry. It says nothing about the actions being good, because every action in that chunk is still a response to a picture taken 530 ms ago. This is the honest limit of remote inference, and it is why this platform states it plainly: inference has to sit next to the servos for fast tasks, and remote inference is viable for slow pick-and-place, not for fast reactive motion. See inference latency for the definition and the client guide for how the local robot client is wired up.
Two mechanisms that keep a fast loop fed by a slow model
Asynchronous inference
The async inference stack splits the system into a PolicyServer holding the model and a RobotClient holding the arm, talking over gRPC. The client keeps stepping through its action queue while the server computes the next chunk, so there are no idle frames. It works with every LeRobot policy, not only SmolVLA.
pip install -e ".[async]"
# terminal 1: the model
python -m lerobot.async_inference.policy_server \
--host=127.0.0.1 \
--port=8080
# terminal 2: the arm
python -m lerobot.async_inference.robot_client \
--server_address=127.0.0.1:8080 \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--robot.id=follower_so100 \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
--task="pick up the cube" \
--policy_type=smolvla \
--pretrained_name_or_path=user/my_smolvla \
--policy_device=cuda \
--actions_per_chunk=50 \
--chunk_size_threshold=0.5 \
--aggregate_fn_name=weighted_average \
--debug_visualize_queue_size=TrueTwo knobs matter. actions_per_chunk is how many actions come back per call, capped at the policy's trained maximum. chunk_size_threshold is the queue fill fraction at which the client sends a fresh observation. The docs recommend 0.5 to 0.6 and note that values near 0.0 collapse to synchronous behaviour while values near 1.0 send an observation every step. Turn on --debug_visualize_queue_size and watch the queue depth. If it repeatedly touches zero, either lower your fps or raise the threshold.
As of 2026-08-24, async.mdx lists the default as 0.7 in its parameter table, while the description in the very same row says a fresh observation goes out once the queue is at or below 50% full, and the copy-paste command directly above passes 0.5. The dataclass settles it: RobotClientConfig.chunk_size_threshold in src/lerobot/async_inference/configs.py defaults to 0.5. Do not infer the value you are running from the docs. Pass the flag explicitly and confirm it in your own logs. This is the kind of small discrepancy that costs an afternoon of confused tuning.
Real-Time Chunking
RTC is the sharper tool. Instead of merely overlapping computation with execution, it treats the new chunk as an inpainting problem: the actions guaranteed to execute before the new chunk arrives are frozen, and the rest are generated conditioned on them. The paper's framing is that it applies to any diffusion-based or flow-matching VLA out of the box with no re-training. Their real-world setup ran Pi0.5 with H = 50 at a 20 ms timestep and 5 denoising steps, giving 76 ms of model latency for the baselines and 97 ms for RTC, and they then injected an extra 100 ms and 200 ms to simulate a distant cloud server. RTC showed no degradation across those delays while synchronous execution degraded linearly.
LeRobot ships it. lerobot-rollout takes --inference.type=rtc alongside the default sync engine.
lerobot-rollout \
--strategy.type=base \
--policy.path=lerobot/pi0_base \
--inference.type=rtc \
--inference.rtc.execution_horizon=10 \
--inference.rtc.max_guidance_weight=10.0 \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
--task="pick up cube" --duration=60| RTC setting | Default | What it controls |
|---|---|---|
| enabled | True | Whether RTC is active at all |
| mode | guided | guided is the inference-time Jacobian guidance from the paper; trained hard-inpaints a prefix and needs a checkpoint trained for it |
| execution_horizon | 10 | How many actions are committed before the next chunk takes over. At 30 Hz that is 333 ms |
| max_guidance_weight | 10.0 | Clip on the guidance weight during denoising |
| prefix_attention_schedule | LINEAR | How attention to the frozen prefix ramps across the chunk |
| queue_threshold (engine) | 30 | Queue depth at which the engine requests a new chunk |
That default execution horizon of 10 is worth noticing: it lands in exactly the band the SmolVLA ablation and the Diffusion Policy ablation identified as good, 8 to 10 steps. Treat that as two independent lines of evidence rather than three, because the SmolVLA ablation and the LeRobot default come from the same team at Hugging Face; the Diffusion Policy number is the outside check. Two groups landing in the same band is about as close to a settled answer as this field gets.
Interpolation, if you want commands faster than inference
There is a third, smaller lever. RolloutConfig has interpolation_multiplier (default 1). With multiplier N the control loop runs N ticks per policy cycle: commands go out at fps times N Hz while inference and dataset recording advance once per cycle at fps Hz. The CycleTimer docstring gives the concrete case, that at 30 FPS with multiplier 2 a 25 ms policy tick followed by a 5 ms interpolated tick still fits the 33.3 ms cycle. This buys smoothness on the wire, not freshness in the observation. Do not confuse the two.
Doing it yourself against doing it here
You own every millisecond, which also means you own every millisecond of debugging. The full path for a local GPU box sitting next to the arm:
- 1Install LeRobot with the async extras
The async stack needs gRPC dependencies that are not in the base install.
bashgit clone https://github.com/huggingface/lerobot.git cd lerobot pip install -e ".[async]" - 2Establish the floor with no policy at all
Run a teleoperation loop at 30 Hz and confirm the cadence report is clean before you add a model. If observe and send alone cannot hold the rate, no amount of inference tuning will help.
bashlerobot-teleoperate \ --robot.type=so100_follower --robot.port=/dev/ttyACM0 --robot.id=follower \ --teleop.type=so100_leader --teleop.port=/dev/ttyACM1 --teleop.id=leader \ --fps=30 - 3Add cameras one at a time
Each camera adds a read to the observe section. LeRobot's OpenCV camera reads on a background thread with a 200 ms async_read timeout, so a camera that cannot hold 30 fps shows up as a loop overrun rather than as a camera error.
bashlerobot-find-cameras opencv - 4Run the policy synchronously first, then measure
The sync engine does one policy call per control tick. It is the wrong production choice for anything but ACT, and the right diagnostic choice, because the cadence report attributes the cost to infer.
bashlerobot-rollout \ --strategy.type=base \ --policy.path=outputs/train/act_so100/checkpoints/last/pretrained_model \ --policy.n_action_steps=10 \ --robot.type=so100_follower --robot.port=/dev/ttyACM0 \ --fps=30 --duration=60 --task="pick up cube" - 5Switch to RTC once you know the number
If infer alone exceeds the tick budget, move to --inference.type=rtc and set execution_horizon to roughly the number of ticks your inference takes, with headroom.
bashlerobot-rollout \ --strategy.type=base --inference.type=rtc \ --inference.rtc.execution_horizon=10 \ --policy.path=user/my_pi05 \ --robot.type=so100_follower --robot.port=/dev/ttyACM0 \ --fps=30 --duration=60 --task="pick up cube"
Budget a day for the first pass, most of it spent on camera enumeration and USB bandwidth rather than on anything to do with models. The failure-mode index is worth a skim before you start.
The platform removes the GPU provisioning step and leaves the latency physics exactly where it was, which is the honest way to describe it.
- Train on a rented GPU from a form: pick model, dataset and hyperparameters, and the backend rents a card on a spot market by required VRAM and writes checkpoints to object storage. Start from the training matrix for the model and arm combination you want.
- Serve the resulting checkpoint with
/api/inference/pod, which auto-provisions a cloud GPU pod running the policy. Your local robot client talks to that endpoint. - Pods carry an idle watchdog and destroy themselves after an idle period, so a forgotten pod does not keep billing silently.
- Drive all of it from a terminal via the CLI or from an agent via the MCP server, which expose the same operations.
- No hardware yet? A physical arm streams on /live with no signup, queue-based, so you can feel what a teleoperation round trip is like before you build anything.
A cloud pod is by definition not next to your servos. Every observation crosses the public internet twice. That is fine for slow, quasi-static pick-and-place and it is the wrong architecture for fast reactive motion, and no amount of chunking changes it. If your task needs sub-100 ms reaction, run inference locally and use the platform for training and datasets instead.
On cost, since it is part of the decision: an A100 or H100 tier run (GR00T N1.7, GR00T N1.5, Pi0.5) takes 3 to 6 hours at 1.20 to 2.00 USD per hour, roughly 4 to 12 USD. A 24 GB tier run (SmolVLA, ACT) takes 2 to 5 hours at 0.30 to 0.60 USD per hour, roughly 1 to 3 USD. Details on the pricing page.

Reading the cadence report
You do not have to guess at any of this. Every LeRobot loop that drives hardware at a fixed rate uses the same CycleTimer, and it warns when the loop body cannot fit the budget. The message is a literal string in the source, and it names the three causes in roughly the order they turn out to be the culprit.
# lerobot-rollout (records_data=False)
WARNING Control loop is running slower (21.4 Hz) than the target FPS (30 Hz).
Robot control might be unstable.
Common causes are: 1) Camera FPS not keeping up
2) Policy inference (action or text) taking too long
3) CPU starvation
# a recording loop (records_data=True) swaps the middle clause for:
Dataset frames might be dropped and robot control might be unstable.At the end of a run the timer prints a full block: effective cadence against target, how many ticks went over the work budget with mean and worst values in milliseconds, ticks with no action to send (the inference engine starved), and a per-section breakdown giving mean, worst and share of work for observe, process_obs, infer, send, telemetry and query. That per-section breakdown is the whole diagnostic. If observe dominates, it is cameras or the servo bus. If infer dominates, it is the model, and you are choosing between a smaller policy, fewer denoising steps, compilation, or RTC.
First, drop max_relative_target if you set it and no longer need it: the SO follower performs an extra sync_read of Present_Position on every send_action when it is set, which is a second bus round trip per tick. Second, try --use_torch_compile=true, a flag on lerobot-rollout with a two-inference warmup. NVIDIA's own numbers show torch.compile cutting the GR00T N1.7 action head from 48.2 ms to 12.0 ms on an H100.

A worked budget for an SO-100 at 30 Hz
Putting it together for a concrete setup: one SO-100 follower on a USB serial adapter, two 640x480 cameras at 30 fps, a policy on a local 4090. The point of the table is the structure. Replace each row with your own measured section timings from the cadence report and you have a real budget rather than a sketch.
| Budget line | Where the number comes from | Effect on observation age |
|---|---|---|
| Camera frame age | Up to one frame period at 30 fps, because async_read hands you the latest frame from a background thread | Up to 33 ms, plus sensor exposure and USB transfer |
| Joint read | One sync_read over a Feetech bus at 1 Mbaud | Small, but it is serial: a leader-follower rig or a second arm doubles it |
| Preprocessing | 6.2 to 9.5 ms on GR00T N1.7 across the devices in NVIDIA's timing table above | Add it, do not ignore it |
| Inference | 20 ms (ACT) to 485 ms (Pi0.5) per the platform catalog | The dominant term for everything except ACT |
| Transport | 13 ms wired LAN in the best case Pi0 reports; assume worse and measure your tail | Added twice, on the observation up and the chunk down |
| Queue position | Actions executed since the chunk arrived, times 33.3 ms | The term chunking creates and the one people forget |
The last row is the one to internalise. If you set your execution horizon to 10, the oldest action you execute from a chunk carries an extra 333 ms of age on top of everything above. Set it to 50 and that becomes 1.67 s, and the SmolVLA numbers tell you what happens next. Recording the episodes at the same rate you intend to run at keeps the two sides of this honest; the recording tutorial covers that part.
When 30 Hz is the wrong target
Nothing here argues that 30 Hz is sacred. ACT's original setup ran at 50 Hz, and the paper measured what dropping to 5 Hz costs: a 62% increase in teleoperation time on the same hardware. OpenVLA-OFT's real robot ran at 25 Hz, explicitly reduced from 50 Hz to make training faster while still keeping control smooth. Pi0 targets up to 50 Hz.
The rate to pick is the one your task needs and your LeRobot dataset was recorded at. Recording and inference have to agree; a policy trained on 30 fps data does not get smoother when you run it at 60 Hz, it just gets asked to predict a timescale it never learned. If your task is quasi-static, 10 Hz with a fresh observation every step will beat 30 Hz with a 50-step open-loop chunk, and it costs less GPU time. If your task involves catching, balancing or anything with contact dynamics, you need both the rate and the freshness, which in practice means a small model running locally. The model arena has 85 VLA models with 332 benchmark results, each value linked to its source, if you want to widen the search beyond the five trainable here.
Where to go next
- The complete SO-100 setup guide covers the hardware half of the loop, which is where the observe section's cost comes from.
- The VLA overview explains what these models are actually doing with the milliseconds they take.
- The Pi0 flow-matching walkthrough goes deeper on why denoising steps exist at all, which is the knob behind most of the latency numbers above.
- Run your first policy is the shortest path to having a loop you can measure, and ACT on the SO-100 is the cheapest model to measure it with.
- The training docs and the SO-100 LeRobot page cover the parts either side of deployment.
Do I need 30 Hz, or is that just a convention?▾
It is a convention with a reason: it is the frame rate most USB webcams deliver, and LeRobot's rollout config defaults fps to 30.0. What matters more is that your inference rate matches the rate your dataset was recorded at. ACT's original ALOHA setup ran at 50 Hz; OpenVLA-OFT's bimanual ALOHA ran at 25 Hz, deliberately reduced from 50. Quasi-static tasks are usually fine slower, with the saved budget spent on more frequent observations.
If action chunking fills the queue, why does latency still matter?▾
Because a full queue and a fresh observation are different things. Chunking guarantees you always have an action to send. It does not make that action a response to the current state of the world. The SmolVLA LIBERO ablation isolates exactly this: executing 50 actions from a chunk before re-observing scores 51.8% average success, executing 10 scores 82.8%. Same model, same chunk, only the re-observation interval changes.
What execution horizon should I start with?▾
Ten steps is a defensible default. LeRobot's RTC config ships execution_horizon = 10 and the SmolVLA ablation found 10 the best of the values it swept, but those two come from the same team, so count them once. The independent check is the Diffusion Policy ablation, which found an action horizon of 8 optimal for most tasks tested. Start at 10, and only go higher if your inference plus round trip genuinely cannot fit inside 333 ms.
Can I run a 3 B parameter VLA on a Jetson next to the arm?▾
You can run it, but check the rate first. NVIDIA's published GR00T N1.7 timings with 4 denoising steps and one camera give 144.9 ms end to end on AGX Thor in PyTorch eager (6.9 Hz) and 342.8 ms on Orin (2.9 Hz). TensorRT brings AGX Thor to 93.8 ms (10.7 Hz). Those are real options for slow manipulation with chunking. They are not 30 Hz closed-loop control.
Is asynchronous inference always better than synchronous?▾
Not always, and the SmolVLA paper is honest about it. Across three real-world tasks async completed the average task in 9.7 s against 13.75 s for sync, roughly 30% faster, and finished 19 pick-and-place cycles in fixed time against 9. But average success was 73.3% for async against 78.3% for sync, and on the sorting task specifically async dropped from 70% to 50%. You are trading some accuracy for a lot of speed.
Where does the platform's 20 to 485 ms range come from?▾
It is the per-action-step inference latency of the five trainable policies in the catalog: ACT at 20 ms, GR00T N1.7 at 152 ms, GR00T N1.5 at 165 ms, SmolVLA at 245 ms and Pi0.5 at 485 ms. Those are the numbers the product pages, the CLI and the MCP tools all read from, and they are the ones to budget against when you plan a loop.
Feel the latency before you design around it
Drive a real SO-100 in your browser with no signup, compare the five trainable policies side by side, and see what a GPU run costs. Three ways to start without owning a robot.
Try it now
Sources
- LeRobot: Asynchronous Inference tutorial
- lerobot/docs/source/async.mdx (actions_per_chunk, chunk_size_threshold)
- Asynchronous Robot Inference: Decoupling Action Prediction and Execution (Hugging Face, 10 July 2025)
- Real-Time Execution of Action Chunking Flow Policies (Black, Galliker, Levine, 2025)
- Physical Intelligence: Real-Time Chunking research page
- SmolVLA: A Vision-Language-Action Model for Affordable and Efficient Robotics
- Learning Fine-Grained Bimanual Manipulation with Low-Cost Hardware (ACT)
- Diffusion Policy: Visuomotor Policy Learning via Action Diffusion
- Bidirectional Decoding: Improving Action Chunking via Guided Test-Time Sampling
- Fine-Tuning Vision-Language-Action Models: Optimizing Speed and Success (OpenVLA-OFT)
- pi0: A Vision-Language-Action Flow Model for General Robot Control
- NVIDIA GR00T N1.7 3B model card, inference timing table
- LeRobot CycleTimer: cadence pacing and slow-loop reporting
- lerobot-rollout: policy deployment engine with sync and RTC inference
- LeRobot RTCConfig defaults (execution_horizon, max_guidance_weight)
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started