SmolVLA Remote Inference: From 0.55 Hz to 3 Hz
Mobility the other way โ instead of putting wheels under the arm, the model's own compute learned to move around the house
Starting point
SmolVLA training and a first Pi 5 rollout test (previous session) had measured on-Pi CPU inference at 0.55 Hz against a 30 Hz target โ roughly 54ร too slow, 1.83s mean / 97.4s worst-case latency per action. Confirmed compute-bound, not memory-bound: the 4GB Pi loaded the 865MB / 450M-parameter checkpoint without issue, and since every Pi 5 RAM variant shares the identical CPU, more RAM wouldn't have helped. The task itself still completed despite the extreme slowness โ the trained policy was sound, this was purely a deployment-speed problem.
Goal for the day: get the model's forward pass off the Pi's CPU and onto the ML Desktop's GPU, without touching training, recording, or LeKiwi's separate Pi.
Attempt 1 โ USB/IP full-device passthrough (abandoned, but genuinely useful)
Idea: instead of splitting the code, split the hardware connection. Use USB/IP so the ML
Desktop sees the Pi's USB-connected arm and cameras as if locally attached, and run the existing, unmodified
lerobot-rollout command on the Desktop instead of the Pi โ zero reimplementation of LeRobot's
internal preprocessing pipeline.
Validated before building anything
- LAN bandwidth (
iperf3): 173 Mbit/s stable, zero retransmissions, Pi โ Desktop over WiFi. - Camera format (
v4l2-ctl): both cameras confirmed running MJPEG (compressed), not raw YUYV โ real bandwidth need well under the measured headroom. Green light to proceed.
Setup
Installed usbip on both machines, identified and mapped all relevant USB devices by busid (follower arm, main camera, gripper camera โ one camera was mislabeled "Webcam Vitade AF" by lsusb's generic vendor DB, correctly identified as an Innomaker unit via v4l2-ctl --list-devices), bound all three on the Pi, attached all three from the Desktop. All three succeeded, confirmed via lsusb and device listings on the Desktop.
Result: cameras worked, the arm's motor bus did not
Running the existing ACT rollout command (deliberately used instead of SmolVLA, to isolate variables โ see below) against the USB/IP-attached devices: cameras connected fine, but the arm failed immediately on the very first low-level write to the motor bus:
ConnectionError: Failed to write 'Homing_Offset' on id_=1 with '0' after 1 tries. There is no status packet!
Consistent on retry and on a fresh first write โ not a calibration-timing issue, but the basic serial read/write mechanism failing over the network hop.
Why ACT, not SmolVLA, for this test: a known-working policy (ACT, already at 86.3% in prior testing) meant a failure could only mean "the hardware connection doesn't work," not get tangled up with SmolVLA's separate, already-known CPU-speed issue. The USB/IP failure was in the hardware layer, below any policy โ SmolVLA would have hit the identical error for the identical reason.
Cleanup: all three devices detached/unbound, Pi restored to its prior working state, the v1 inference_server.py and iperf3 test server both stopped and retired.
Value despite not shipping: confirmed LAN bandwidth and camera compression are fine for a network-based approach, conclusively ruled out "just forward all the hardware," and โ reading LeRobot's actual rollout code to debug this attempt (sync.py, SyncInferenceEngine, the rollout/strategies/ and rollout/inference/ packages) is exactly what made Attempt 2 possible.
Attempt 2 โ Surgical single-line patch (working)
Design
Code reading (triggered by debugging Attempt 1) found a clean interception point: SyncInferenceEngine.get_action() in lerobot/src/lerobot/rollout/inference/sync.py, specifically:
action = self._policy.select_action(observation)
At this point observation is already a fully preprocessed dict of torch tensors โ camera images normalized, camera keys renamed, state normalized. A remote call could ship observation as-is (via torch.save/torch.load over HTTP) without reimplementing or guessing at any of LeRobot's own preprocessing logic โ unlike Attempt 1's original custom server design, which would have needed to track LeRobot's fast-moving internals by hand.
Two files
remote_inference_server.py(ML Desktop): loads the trained checkpoint once (SmolVLA or ACT, via--policy-type), exposes/select_actionover plain HTTP (FastAPI/uvicorn), callspolicy.select_action()on whatever tensor dict it receives, returns the action the same way.patch_sync_for_remote_inference.py(Pi, run once): a small, idempotent, self-backing-up script that editssync.pyin place, wrapping the line above in an environment-variable check:
Local inference remains the default (env var unset); everything else in the rollout pipeline โ camera capture, motor writes, calibration, pre/post-processing โ is untouched. Designed to survive a future LeRobot update: re-running the patch script against a new_remote_url = _os.environ.get("LEROBOT_REMOTE_INFERENCE_URL") if _remote_url: action = _remote_select_action(observation, _remote_url) else: action = self._policy.select_action(observation)sync.pyeither re-applies cleanly or says plainly that the target line moved, rather than silently doing nothing.
Deployment
- Server started on the Desktop:
--policy-type smolvla --port 8766(a different port from the retired v1 server's 8765) - Rollout run on the Pi with
LEROBOT_REMOTE_INFERENCE_URL=http://192.168.2.210:8766prefixed onto the same SmolVLA rollout command used for the original 0.55 Hz baseline (--rename_mapstill required at rollout time, consistent with the original training/rollout gotcha)
Results
First live test: the arm actively searched and moved continuously โ no multi-second freezes, unlike the CPU baseline โ and completed a genuine grasp-and-place. The object was manually nudged slightly closer partway through to keep the session moving at the still-modest ~2โ3 Hz rate, but the grasp, transport, and placement into the target were performed entirely by the policy once the object was in reasonable reach. Confirms pipeline correctness: preprocessing โ remote call โ postprocessing โ action execution all functioning together.
Full cadence run โ exact numbers from the control loop's own summary
Cadence summary โ whole run ยท target 30 Hz (33.3 ms budget per tick): 2513 ticks, 2512 judged
effective cadence: 3.02 Hz over 832.0 s measured
ticks over the 33.3 ms work budget: 2512/2512 (100.0%) โ work mean 330.8 ms, worst 1157.0 ms
loop-body steps (share of measured work):
observe mean 3.18 ms ยท worst 3.67 ms ยท 1.0% of work ยท 2513 calls
process_obs mean 0.02 ms ยท worst 0.10 ms ยท 0.0% of work ยท 2513 calls
infer mean 327.42 ms ยท worst 1153.39 ms ยท 98.9% of work ยท 2513 calls
send mean 0.20 ms ยท worst 0.58 ms ยท 0.1% of work ยท 2513 calls
telemetry mean 0.01 ms ยท worst 0.12 ms ยท 0.0% of work ยท 2513 calls
query mean 0.01 ms ยท worst 0.14 ms ยท 0.0% of work ยท 2513 calls
pacing headroom: 0.0 ms slept per tick on average โ loop is saturated
| Metric | Before (local Pi CPU) | After (remote GPU) |
|---|---|---|
| Effective cadence | 0.55 Hz | 3.02 Hz (2,513 ticks / 832s) |
| Worst-case single-action latency | 97.4 s | 1.16 s |
| Mean single-action latency | 1.83 s | 0.33 s |
| Pi CPU load during rollout | ~100% | ~5โ6% |
| Pi temperature during rollout | 60โ68ยฐC | ~50ยฐC |
| Task outcome | succeeded (slowly) | succeeded โ first confirmed correct grasp-and-place through the full remote-inference pipeline |
The Pi CPU/temperature numbers are an independent confirmation, not just a byproduct of the Hz measurement โ they directly show the heavy computation left the Pi, matching the architecture's intent exactly: Pi as thin client for sensing and actuation, Desktop GPU doing the actual model math.
Known remaining bottleneck โ precisely quantified, not yet fixed
infer (this server's round trip) accounts for 98.9% of all work time in the loop โ every other step is under 1% individually. Root cause: the current server sends full-resolution, uncompressed float32 image tensors over the network every tick โ a 640ร480 + 320ร240 float32 pair is roughly 4โ5 MB per observation, which at the measured ~173 Mbit/s LAN speed accounts for roughly 200ms of pure transfer time per tick, closely matching the measured 327ms mean infer time.
Explicitly deferred to next session
- Image-compression optimization โ the known lever to close most of the remaining gap to real-time.
- Decide the "default" path โ currently requires manually setting
LEROBOT_REMOTE_INFERENCE_URLand separately starting the server; worth wrapping into a single convenience script/alias once the design is finalized. - Whether/how to reuse this pattern for LeKiwi โ the same thin-client-Pi + GPU-inference-server architecture should apply directly once LeKiwi's own SmolVLA work starts, on its own dedicated Pi 5.
- Full rollout cadence confirmation with task-success tracking โ today's run measured cadence precisely but wasn't set up to track grasp-success rate across many trials (in the spirit of the lux/rollout investigation's methodology); worth doing once the compression fix is in.
Summary
- SmolVLA's on-Pi CPU inference (0.55 Hz) was confirmed compute-bound, not memory-bound, and not fixable by more RAM.
- USB/IP full-device passthrough was tried, validated LAN bandwidth and camera compression, but failed on the arm's CH340 serial adapter โ a known rough edge for USB tunneling over a network hop.
- Reading LeRobot's own rollout internals during that debugging surfaced a single clean interception point โ one line, already-preprocessed tensors โ enabling a minimal, idempotent, self-backing-up patch instead of a hand-rolled reimplementation.
- Remote GPU inference took cadence from 0.55 Hz to 3.02 Hz and worst-case latency from 97.4s to 1.16s, with the first confirmed correct grasp-and-place through the full pipeline.
- The remaining gap to 30 Hz is isolated to one cause (uncompressed image transfer) with a known, unimplemented fix.