The AY-Robots head to head comparison of GR00T N1.7 against Pi0.5, including the per action step latency row
control-loopinference-latencyaction-chunkinglerobotso-100

Latency Budget for 30 Hz Robot Control: Where the Milliseconds Go

AY-Robots ResearchAugust 23, 202626 min read

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.

python
# 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()
The six timed stages of a rollout tick, as named in LeRobot main on 2026-08-24.
StageWhat it doesWhat sets its cost
observesync_read of Present_Position over the servo bus, then one frame per cameraSerial 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_obsResize, normalise, build the observation tensorsImage size and camera count. SmolVLA pads images to 512x512, and GR00T N1.7 timings are published for one camera.
inferThe policy forward pass, or a pop from a queue if a chunk is already in flightModel size and denoising steps. This is the line item this article is mostly about.
sendsync_write of Goal_PositionOne 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 / queryRerun or Foxglove logging, optional text-head queriesUsually small, but display_data=true with uncompressed images is not free.
waitprecise_sleep to the tick deadlineNothing, 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.

The loop is judged on work, not on wall clock

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.

PolicyParamsPer action step33.3 ms ticks consumedGPU tier
ACT~80 M20 ms0.6RTX 4090 or any 24 GB card
GR00T N1.7~3 B152 ms4.6A100 80 GB or H100 80 GB
GR00T N1.5~3 B165 ms5.0A100 80 GB or H100 80 GB
SmolVLA~450 M245 ms7.4RTX 4090 or any 24 GB card
Pi0.5~3 B485 ms14.6A100 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.

The AY-Robots policies page showing a comparison table of ACT, SmolVLA, GR00T N1.5, GR00T N1.7 and Pi0.5 with parameter counts, GPU tier, per-step latency and minimum episode counts
The /policies comparison table. The latency column is the one that decides whether a model can drive a 30 Hz loop at all.

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.

DeviceModeData processingBackboneAction headEnd to endFrequency
H100 80GB HBM3PyTorch eager6.2 ms31.3 ms48.2 ms85.8 ms11.7 Hz
H100 80GB HBM3torch.compile6.2 ms30.4 ms12.0 ms48.6 ms20.6 Hz
H100 80GB HBM3TensorRT full pipeline6.2 ms8.8 ms12.3 ms27.9 ms35.9 Hz
L40PyTorch eager6.6 ms42.8 ms78.9 ms128.3 ms7.8 Hz
L40TensorRT full pipeline6.6 ms13.1 ms18.8 ms38.4 ms26.0 Hz
AGX ThorPyTorch eager8.21 ms55.26 ms81.65 ms144.9 ms6.9 Hz
OrinPyTorch eager9.45 ms127.6 ms205.39 ms342.8 ms2.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.

Denoising steps are a latency dial with a cliff

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_sizen_action_stepsSeconds of actions per call at 30 HzDenoising steps
ACT1001003.33 snot applicable
SmolVLA50501.67 s10
Pi050501.67 s10
Pi0.550501.67 s10
GR00T40401.33 s4 (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.

Chunking, honestly
What it gives you
  • 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.
What it costs you
  • 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-observingSpatialObjectGoalLongAverage success
18994855380.3%
108994915782.8%
307691744270.8%
50 (the whole chunk)5470582551.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.

The default that eats a day

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.

A quick way to tell them apart

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.

  1. 1
    Measure 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
  2. 2
    Convert 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.

    bash
    python3 -c "rtt=45; fps=30; print(f'{rtt/1000*fps:.2f} ticks of transport per action')"
  3. 3
    Add 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.

bash
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=True
From the LeRobot async inference tutorial, adapted to an SO-100 follower on /dev/ttyACM0.

Two 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.

The documented default for chunk_size_threshold contradicts itself

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.

bash
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
The RTC example from the lerobot-rollout docstring, verbatim.
RTC settingDefaultWhat it controls
enabledTrueWhether RTC is active at all
modeguidedguided is the inference-time Jacobian guidance from the paper; trained hard-inpaints a prefix and needs a checkpoint trained for it
execution_horizon10How many actions are committed before the next chunk takes over. At 30 Hz that is 333 ms
max_guidance_weight10.0Clip on the guidance weight during denoising
prefix_attention_scheduleLINEARHow attention to the frozen prefix ramps across the chunk
queue_threshold (engine)30Queue 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:

  1. 1
    Install LeRobot with the async extras

    The async stack needs gRPC dependencies that are not in the base install.

    bash
    git clone https://github.com/huggingface/lerobot.git
    cd lerobot
    pip install -e ".[async]"
  2. 2
    Establish 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.

    bash
    lerobot-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
  3. 3
    Add 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.

    bash
    lerobot-find-cameras opencv
  4. 4
    Run 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.

    bash
    lerobot-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"
  5. 5
    Switch 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.

    bash
    lerobot-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 AY-Robots CLI page showing the install command and the run commands for training and inference from a terminal
The /cli page. Same operations as the web UI, which matters when you want the loop configuration in a script rather than a form.

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.

text
# 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.
The warning template from cycle_timer.py with an example rate filled in. The middle clause depends on whether the loop is recording; your own run prints its own measured Hz.

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.

Two cheap wins before you change models

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.

The AY-Robots MCP server page listing the platform operations exposed as tools to AI agents
The /mcp page. Useful when you want an agent to sweep chunk and horizon settings across runs rather than editing flags by hand.

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 lineWhere the number comes fromEffect on observation age
Camera frame ageUp to one frame period at 30 fps, because async_read hands you the latest frame from a background threadUp to 33 ms, plus sensor exposure and USB transfer
Joint readOne sync_read over a Feetech bus at 1 MbaudSmall, but it is serial: a leader-follower rig or a second arm doubles it
Preprocessing6.2 to 9.5 ms on GR00T N1.7 across the devices in NVIDIA's timing table aboveAdd it, do not ignore it
Inference20 ms (ACT) to 485 ms (Pi0.5) per the platform catalogThe dominant term for everything except ACT
Transport13 ms wired LAN in the best case Pi0 reports; assume worse and measure your tailAdded twice, on the observation up and the chunk down
Queue positionActions executed since the chunk arrived, times 33.3 msThe 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

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
The AY-Robots try page showing three ways to start without owning a robot: drive a real arm, compare models, and rent a GPU
The /try page. Driving the live arm is the cheapest way to build an intuition for what a few hundred milliseconds of round trip feels like in the hand.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started