The AY-Robots try page showing three ways to start without owning a robot: drive a real arm, compare models, rent a GPU
InferencePolicy ServingBatchingLatencyRobot Fleets

Serving One Policy to Several Arms: Batching and Its Ceiling

AY-Robots ResearchAugust 23, 202622 min read

One GPU, several robot arms, one policy. What batching really costs in measured milliseconds, how the queue turns into jitter on each arm, and where the ceiling actually sits.

You have three SO-100 arms on a bench and one rented GPU. The obvious question is whether a single policy server can drive all three, and the obvious hope is that batching makes the extra arms nearly free. Batching does help, and the help is measurable. It is also not free, and the cost lands somewhere uncomfortable: every arm in a batch waits for the whole batch to finish, not for its own share of it.

What you need to know

  • The reference policy servers from NVIDIA, Physical Intelligence and Hugging Face were all written for one robot. None of them batches requests coming from different clients.
  • Batching is cheap on some models and expensive on others. On an L40S, GR00T N1.7 goes from 71.8 ms at batch 1 to 100.9 ms at batch 5. Pi0.5 goes from 73.0 ms to 211.1 ms over the same range.
  • Throughput per second is not what your arm feels. In a synchronised batch every robot waits the full batch latency.
  • Utilisation is the number to watch. Past roughly 70 percent of server capacity, waiting time grows faster than the load that caused it.
  • Queueing does not show up as a slower average. It shows up as jitter, and jitter shows up on the arm as pauses at chunk seams.
  • VRAM is almost never the binding limit. Compute and the deadline are.
  • On this platform, an inference pod is provisioned per policy and carries an idle watchdog. That is a per-policy endpoint, not a fleet scheduler.

The reference servers were written for one arm

Before any arithmetic about batch sizes, there is a fact worth checking in the source: the three serving stacks that most people start from do not serve several robots at once. Not badly, not slowly. They do not do it. Each one assumes a single client holding a single policy, and each one fails differently when a second client shows up.

StackTransport and default portWhat a second client doesFile to read
Isaac-GR00T PolicyServerZeroMQ REQ/REP, port 5555Served one at a time. The run loop is receive, handle, send in strict alternation, so a second request waits for the first to be answered in full.gr00t/policy/server_client.py
openpi WebsocketPolicyServerWebSocket plus msgpack, port 8000Connections are accepted, but each handler coroutine calls the blocking policy.infer, so the event loop runs them one after another. No batching anywhere.src/openpi/serving/websocket_policy_server.py
LeRobot async inference PolicyServergRPC, port 8080, thread pool of 4Ready() calls _reset_server(), which throws away the observation queue and the predicted timestep set. The newcomer resets the incumbent.src/lerobot/async_inference/policy_server.py
The trap that eats a day

In LeRobot's async inference server, Ready() calls _reset_server() with the comment "Flushes server state when new client connects". Plug a second arm into a running server and the first arm's observation queue is silently emptied. There is no error, no warning, and the symptom on the bench is that arm number one starts pausing for no reason the moment you start arm number two. Read policy_server.py before you debug the arm.

This is not an oversight so much as a scope decision. The single-client design is what makes action chunking and asynchronous execution simple: one observation queue of depth one, one policy, one client whose action buffer the server can reason about. Adding a second robot breaks every one of those assumptions at once.

The model side is in better shape. NVIDIA's Policy API documents batched inference explicitly: pass observations with a leading batch dimension and you get actions back with shape (batch_size, action_horizon, action_dim). The tensors are ready for multi-tenancy long before the servers are.

python
# Isaac-GR00T, getting_started/policy.md: the batch dimension already exists
batch_size = 4
observation = {
    "video": {"wrist_cam": np.zeros((batch_size, T_video, H, W, 3), dtype=np.uint8)},
    "state": {"joints": np.zeros((batch_size, T_state, D_state), dtype=np.float32)},
    "language": {"task": [["pick up the cube"]] * batch_size},
}

action, _ = policy.get_action(observation)
# action["action_name"] has shape (batch_size, action_horizon, action_dim)
The policy takes a batch. Nothing in the shipped servers ever assembles one from several robots.

What batching actually costs, in measured milliseconds

The useful measurements come from Armory, a serving system published as Action Chunk Scheduling for Batched Robot Policy Serving (arXiv 2608.00337, 31 July 2026, Georgia Tech). The authors profiled inference latency as a function of batch size for Pi0.5 and GR00T N1.7. The last two columns below are mine: batch latency divided by batch size.

BatchPi0.5, L40SPi0.5, H100GR00T N1.7, L40SPi0.5 per request, L40SGR00T per request, L40S
173.0 ms42.3 ms71.8 ms73.0 ms71.8 ms
2109.8 ms56.0 ms76.5 ms54.9 ms38.3 ms
3142.4 ms66.1 ms83.5 ms47.5 ms27.8 ms
4177.5 ms79.3 ms91.8 ms44.4 ms23.0 ms
5211.1 ms87.2 ms100.9 ms42.2 ms20.2 ms

Read the two L40S columns side by side, because they behave nothing alike. Pi0.5 at batch 5 costs 2.9 times what a single request costs. GR00T N1.7 at batch 5 costs 1.4 times. The paper's explanation is that GR00T is more memory-bound than Pi0.5 in their setup, so the extra samples ride along with weight loading that has to happen anyway. The practical consequence: on the same card, five arms on GR00T are close to free, and five arms on Pi0.5 are close to a full extra card's worth of work. The paper labels the GR00T table preliminary analysis, so treat it as a strong hint rather than a specification.

Why the two curves differ

A second 2026 preprint, PhyAI (arXiv 2608.03682, August 2026), explains the mechanism by profiling execution phases. At batch size one on a Hopper-series GPU, the Pi0.5 action expert accounts for 8.8 percent of estimated FLOPs but 57.2 percent of profiled latency: many small GEMMs, poorly fed. At batch 32 its share of time falls to 13.5 percent and the vision-language path takes over. For GR00T N1.7 on an RTX 5090 they measured the same handover: the action head is 59.2 percent of the time at batch 1, and the backbone reaches 69.7 percent at batch 32. Batching does not speed up your model. It fills in the idle silicon that a batch of one leaves empty.

The Pi0.5 curve is a direct consequence of that architecture. A flow-matching policy runs an iterative action expert on top of a vision-language backbone, and at batch one the expert is starved of work while the backbone is not. The Pi0 flow-matching write-up covers the architecture itself. The serving consequence is that the denoising loop is exactly the part batching repairs, and the vision-language path is the part it cannot.

The AY-Robots policies page comparing GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT by parameters, GPU tier, inference latency and minimum episodes
The five trainable policies with their per-action-step latency. The model you pick decides whether batching is nearly free or nearly linear.

Throughput per second is not the time your arm waits

This is the single most common mistake in fleet inference planning. A batch of 32 that completes in 320 ms is often reported as ten milliseconds per request. No robot in that batch experienced ten milliseconds. Every one of them waited 320 ms.

The number that matters is the full batch latency

PhyAI measured Pi0.5 in BF16 with CUDA Graphs, three camera views, a 50-action chunk and ten Euler steps. On a Hopper-series GPU, batch size 1 takes 22.59 ms and yields 44.26 samples per second. Batch size 32 reaches 100.02 samples per second, but the synchronised batch completes in 319.94 ms, an amortised 10.00 ms per sample. Their own summary: a rollout worker can use the 100 samples per second, while a robot waits for the 320 ms batch to finish. Note this configuration is not the same as Armory's, which is why the batch-1 numbers differ. Always compare batch curves measured under one configuration.

The same document decomposes the end-to-end path into observation, transfer, queue, inference and actuation, and is blunt that the queue term is separate from the inference term: batch measurements are static-batch GPU time and exclude request queues, network transfer and tail latency. That missing queue term is exactly what a shared server introduces and what this article is about.

The arithmetic that decides how many arms fit

Three numbers determine the answer, and you can get all of them in an afternoon. Service time S(B) is how long a batch of size B takes on your card. Capacity is B divided by S(B), in requests retired per second. Demand per arm is the control frequency divided by the number of actions each arm executes before it needs a fresh chunk. Utilisation is demand divided by capacity, and it is the only one of the four you should be nervous about.

Armory's real fleet gives concrete values for demand: ten AgileX PiPER arms at 30 Hz, running a Pi0.5 checkpoint fine-tuned with a prediction horizon of 20. Their quasi-static arms execute up to 20 actions per chunk, which is 0.667 seconds of motion, or 1.5 requests per second per arm. Their dynamic arms cap the execution horizon at 10, which is 0.333 seconds, or 3.0 requests per second per arm. Halving the horizon doubles the load on the server, which is why reactive tasks are expensive to serve.

python
# Substitute your own measured service times. These are Armory's
# GR00T N1.7 figures on an L40S (arXiv 2608.00337, appendix A.3).
S = {1: 0.0718, 2: 0.0765, 3: 0.0835, 4: 0.0918, 5: 0.1009}  # seconds

fc = 30          # control frequency in Hz
h_exec = 20      # actions executed per chunk before a new one is needed
target_rho = 0.7 # leave 30 percent headroom, see the queueing section

per_arm = fc / h_exec                      # requests per second, per arm
for B, s in S.items():
    capacity = B / s                       # requests per second the server retires
    arms = target_rho * capacity / per_arm
    print(f"batch {B}: {s*1000:5.1f} ms/batch  {capacity:5.1f} req/s  {arms:4.1f} arms")
The whole capacity model. Everything else is measurement.
Model and cardBatchFull batch latencyRequests per secondQuasi-static arms (H=20)Reactive arms (H=10)
GR00T N1.7, L40S171.8 ms13.963
GR00T N1.7, L40S383.5 ms35.9168
GR00T N1.7, L40S5100.9 ms49.62311
Pi0.5, L40S173.0 ms13.763
Pi0.5, L40S3142.4 ms21.194
Pi0.5, L40S5211.1 ms23.7115
Pi0.5, H100587.2 ms57.32613

Arm counts assume 30 Hz control and 70 percent target utilisation, and they are my arithmetic on Armory's measured latencies, not a measured fleet size. Treat them as the ceiling you plan against, then verify with real arms.

The count is only half the check. The deadline is the other half. A reactive arm with 10 actions in hand at 30 Hz has 333 ms of motion left. Pi0.5 at batch 5 on an L40S consumes 211 ms of that before a single byte goes back on the wire. Add two 50 ms network hops and you have spent 311 ms of a 333 ms budget on a link that has not hiccupped yet. On the same card, GR00T N1.7 at batch 5 takes 101 ms and leaves genuine headroom. This is why the GR00T against Pi0.5 comparison looks different when you are serving a fleet than when you are serving one arm.

Queueing shows up as jitter, not as a slower average

When a shared server saturates, the mean round trip barely moves. What moves is the spread, and the arm is far more sensitive to spread than to level. A policy that always answers in 200 ms is easy to build around. A policy that answers in 90 ms most of the time and 400 ms sometimes is the one that makes the gripper stutter.

  • The arm pauses briefly at chunk seams, which Armory calls starvation: control steps where the robot has no action left to execute.
  • Two arms running the same checkpoint behave differently, and the difference tracks which one got into the batch.
  • The p99 round trip separates from the median while the median stays flat.
  • Adding a fourth arm degrades all four rather than just the fourth.
  • Motion quality falls off faster on the dynamic task than on the slow one, at the same server load.

The reason is structural. For a single-server first-in-first-out queue with random arrivals, the classic M/M/1 model puts the average wait before service at rho/(1 - rho) service times, where rho is utilisation. At 50 percent load that is one extra service time. At 80 percent it is four. At 90 percent it is nine. Batched serving is not literally M/M/1, since the server pulls several requests at once, but the shape of the curve is the same and it explains the field report that everyone has: four arms are fine, six arms fall over, and nothing in between felt like a warning.

Armory's network sweeps add a useful correction to intuition. With a one-way median of 50 ms, they swept jitter from mild to extreme and found starvation rate and system throughput stayed nearly flat, because their delay estimator and the arm's own action buffer absorb individual late packets. Median delay, swept from 25 ms to 500 ms, had a direct monotonic effect instead, and all schedulers degraded the same way. Their conclusion is worth memorising: scheduling policy cannot compensate for raw transport delay. The jitter that hurts is the queue's, not the network's, because queue jitter is correlated with load rather than random.

Log p99, not the mean

A mean round trip hides the exact failures you care about. Record p50 and p99 per arm, and record them again with each additional arm on the server. If p99 climbs while p50 sits still, you are queueing, not computing, and no amount of TensorRT will fix it. See policy freezes mid-motion for what this looks like from the arm's side.

Measure it before you believe it

  1. 1
    Stand up a single-client server first

    Get one arm working end to end before adding anything. LeRobot's async inference server is the shortest path and supports act, smolvla, diffusion, tdmpc, vqbet, pi0, pi05 and groot.

    bash
    python -m lerobot.async_inference.policy_server \
         --host=0.0.0.0 \
         --port=8080
  2. 2
    Attach the robot client and watch the action queue

    The client, not the server, is where starvation is visible. The --debug_visualize_queue_size flag plots the queue depth at runtime. Defaults that matter: chunk_size_threshold is 0.5, which means a new observation is sent when half the chunk is left.

    bash
    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 \
        --task="pick up the cube" \
        --policy_type=smolvla \
        --pretrained_name_or_path=user/model \
        --policy_device=cuda \
        --actions_per_chunk=50 \
        --chunk_size_threshold=0.5 \
        --aggregate_fn_name=weighted_average \
        --debug_visualize_queue_size=True
  3. 3
    Measure your own S(B) curve

    Do not import someone else's table. Time batches on the card you will rent, with your camera count and your chunk length, because both change the answer. GR00T's client takes a batched observation directly.

    python
    import time
    from gr00t.policy.server_client import PolicyClient
    
    client = PolicyClient(host="127.0.0.1", port=5555)
    
    for B in (1, 2, 3, 4, 5):
        obs = make_observation(batch_size=B)   # leading batch dimension
        for _ in range(5):
            client.get_action(obs)             # warm up, then time
        t0 = time.perf_counter()
        for _ in range(30):
            client.get_action(obs)
        print(B, round((time.perf_counter() - t0) / 30 * 1000, 1), "ms")
  4. 4
    Separate server compute from queue and network

    openpi's server puts its own timing in every reply, which lets you subtract compute from the round trip and see what the wire and the queue cost you. If infer_ms is stable while your measured round trip is not, the problem is not the model.

    python
    result = client.infer(observation)
    print(result["server_timing"]["infer_ms"])     # pure model time on the server
    print(result["server_timing"]["prev_total_ms"]) # previous request, including send
  5. 5
    Add arms one at a time and keep the log

    Add one arm, run twenty minutes, record p50, p99 and starvation count per arm. Repeat. The number where p99 doubles is your real ceiling, and it will be lower than the capacity arithmetic suggested.

    bash
    # GR00T's own server, for the same experiment on the NVIDIA stack
    uv run python gr00t/eval/run_gr00t_server.py \
        --model-path <CHECKPOINT_PATH> \
        --embodiment-tag NEW_EMBODIMENT \
        --device cuda:0 \
        --host 0.0.0.0 --port 5555
Watch the client timeout while you experiment

Isaac-GR00T's PolicyClient sets a 15000 ms send and receive timeout by default. When it fires, the ZeroMQ REQ socket is left waiting for a reply that will never come, so the client rebuilds the socket and re-raises. If your queue experiment pushes waits past 15 seconds you will see socket churn rather than a clean latency number.

The AY-Robots CLI page showing the install command and the run commands for driving training and inference from a terminal
Everything in this article is a terminal workflow. The CLI page carries the install and run commands.

Doing it yourself against doing it here

You are not configuring a feature, you are writing a scheduler. Armory's own architecture is the honest minimum: a frontend process that owns the WebSocket connections and writes observations into shared memory, a scheduler process that maintains a server-side mirror of every arm's action queue, in-flight chunks, execution horizon and communication delay, and an engine process that does nothing but run forward passes and profile its own service time per batch size at startup.

  1. Accept N connections and keep only the newest observation per arm. Stale observations are worse than no observation.
  2. Profile S(B) at startup, for every batch size you will use, on the actual card.
  3. Track each arm's remaining queue depth and its deadline, and drop a request if the arm has not consumed a minimum number of actions since the last one.
  4. Cap the batch. Armory found batch 3 best in simulation with ten robots, and batch 3 or 5 in the real world depending on the mix.
  5. Pick a scheduling rule. Round robin and earliest deadline first are competitive when all arms are alike; heterogeneous fleets need something that models starvation.

If you would rather not write the scheduler, NVIDIA Triton's dynamic batcher already has the knob set that this problem needs: preferred_batch_size, max_queue_delay_microseconds, priority_levels and per-queue policies. By default it forms batches as large as possible without delaying requests. The catch is that Triton knows nothing about action queues or execution horizons, so it optimises the wrong objective: it protects request latency, not the arm's remaining motion budget.

Do not skip the mirror

A batcher that only sees requests will happily serve the arm that just got a fresh chunk and skip the one that is two control steps from starving. Armory's whole result is that modelling each arm's action buffer is what turns a generic batcher into a robot policy server, worth up to 18 percent system throughput in their real-world heterogeneous runs.

Sharing one GPU across several arms
Advantages
  • One rented card instead of one per arm, which is the entire reason anyone does this.
  • Batching is close to free on a memory-bound policy. GR00T N1.7 at batch 5 on an L40S costs 1.4 times a single request while serving five arms.
  • One checkpoint, one set of normalisation statistics, one thing to update when the policy improves.
  • The GPU stops idling between chunks. With a single arm at a 20-action horizon and 30 Hz, the card is busy for about 72 ms out of every 667 ms.
  • Server-side timing becomes a fleet metric, so you find out which arm is struggling from the log rather than from the bench.
Trade-offs
  • Every arm in a synchronised batch waits for the whole batch, so per-arm latency rises even as throughput does.
  • Latency becomes load-dependent. The same policy behaves differently depending on what a colleague started five minutes ago.
  • None of the reference servers do this. You are writing and maintaining a scheduler.
  • Raw transport delay cannot be scheduled away, and it dominates once the median round trip passes roughly 100 ms.
  • A reactive task and a quasi-static task on the same server pull the scheduler in opposite directions, and the reactive one loses by default.
  • A batch requires one model and one input shape. Different checkpoints cannot share a batch, so a mixed fleet needs a pod per policy anyway.
The AY-Robots MCP server page listing the platform operations exposed as tools to AI agents
The MCP server exposes the same operations as the CLI, which is how a scheduler or an agent drives provisioning without a browser.

Where the ceiling really is

CeilingWhat sets itHow you notice
ComputeThroughput saturates with batch sizeAdding arms stops adding throughput. GR00T N1.7 on an RTX 5090 already reaches 92.2 percent of its batch-32 throughput at batch 8.
DeadlineFull batch latency plus two network hops must fit inside the arm's remaining chunkStarvation rate climbs, motion gets jerky at chunk seams, dynamic tasks fail before slow ones do.
Arrival rateArms request faster than the server retires requestsp99 round trip separates from p50 and every arm degrades together.
VRAMRarely bindingopenpi lists more than 8 GB for inference. LeRobot's async guide puts pi0 at 14 GB and SmolVLA at about 2 GB. An 80 GB card holds several copies, but copies do not share compute.

That last row deserves a sentence of its own, because the tempting workaround is to run five server processes on one big card instead of building a batcher. The arithmetic says no. Five serialised GR00T N1.7 requests on an L40S cost five times 71.8 ms, which is 359 ms of GPU time. One batch of five costs 100.9 ms. Separate processes give you the memory isolation and none of the amortisation, plus context-switch overhead you did not have before. If you are going to share a card, share it properly.

There is also a ceiling that no amount of engineering moves: the model's own service time. The platform lists 20 ms per action step for ACT, 152 ms for GR00T N1.7, 165 ms for GR00T N1.5, 245 ms for SmolVLA and 485 ms for Pi0.5. A vision-language-action model whose forward pass is half a second is one you can serve to very few reactive arms, no matter how good your scheduler is. Choosing the model is choosing the fleet size, which is worth thinking about before training rather than after. The arena compares 85 VLA models with 332 benchmark results if you want the wider field, and the VLA overview covers why these models are shaped the way they are.

Where this does not help

Batching is a throughput technique applied to a latency problem, and it is worth being clear about which problems it leaves untouched. It does not make any single arm more responsive. It does not shorten the network path. It does not rescue a task that was already marginal at batch one. Real-time chunking, from Real-Time Execution of Action Chunking Flow Policies (Black, Galliker and Levine, June 2025), buys genuine latency tolerance, holding up under 100 ms and 200 ms of injected delay where synchronous execution degrades linearly. But PhyAI states the limit plainly: real-time chunking gives inference more time by overlapping it with execution, and it does not reduce network or queueing delay.

The honest latency limit

Inference has to sit next to the servos for fast tasks. The control loop is 20 to 485 ms per action step depending on the model, and adding public-internet round trips turns a working policy into a hesitant one. Remote inference is viable for slow pick-and-place, not for fast reactive motion. Batching makes this worse rather than better, because it puts a queue in front of the network. If your task is reactive, run ACT next to the arm and do not share the card.

The same applies to the platform. An inference pod is provisioned per policy with an idle watchdog, which solves GPU provisioning and cost control. It is not a fleet batcher, and pretending otherwise would just move the surprise later. If you want to see the pieces working before committing hardware, the live arm streams a real SO-100 with no signup, and the try page lays out the three ways to start. When you are ready to record your own data, the desktop client writes LeRobot datasets straight from a teleoperation session, and the dataset directory lists public ones.

If none of this applies yet because you are still getting one arm to move, start there instead: the SO-100 setup guide walks the whole path from build to teleoperation to training, and run your first policy is the shortest version of the last step. Fleet arithmetic only pays off once a single arm does the task reliably, and most teams reach for a second arm several weeks before that is true.

Drive the whole loop from a terminal

Provisioning a pod, pointing a client at it and reading back timing is a scripting job, not a clicking job. The CLI exposes the same operations as the web app, so your capacity experiment can be a shell loop.

See the CLI

Frequently asked questions

Can I just point two arms at one policy server?

With the reference servers, no, or not safely. Isaac-GR00T's ZeroMQ server answers one request at a time. openpi accepts both connections but runs the blocking inference call inside the event loop, so they serialise. LeRobot's gRPC server calls _reset_server() whenever a new client sends Ready(), which flushes the first arm's observation queue. Two arms on one endpoint means time-sharing at best and silent state loss at worst.

Does batching make each arm faster?

No. Batching raises the number of requests the GPU retires per second and raises the latency each individual request experiences. In a synchronised batch every robot waits for the full batch. On an L40S, Pi0.5 at batch 5 takes 211.1 ms against 73.0 ms at batch 1. You are trading per-arm responsiveness for fleet capacity, deliberately.

How many arms fit on one GPU?

Divide the batch size by the measured batch latency to get requests per second, divide by each arm's request rate (control frequency divided by actions executed per chunk), and keep utilisation near 70 percent. With Armory's GR00T N1.7 figures on an L40S at batch 5 and quasi-static arms at 30 Hz with a 20-action horizon, that arithmetic gives about 23 arms. The same arithmetic for Pi0.5 on the same card gives about 11, and for reactive arms with a 10-action horizon roughly half of each. Verify on hardware; these are planning numbers.

Why does my arm stutter only when another arm is running?

Because your request is now waiting behind someone else's. Queue wait grows non-linearly with utilisation, so the mean round trip barely moves while the p99 doubles. The arm exhausts its action queue during the long requests and pauses. Log p50 and p99 per arm, add arms one at a time, and watch where p99 separates from p50.

Do all arms have to run the same policy?

To share a batch, yes. A batch is one model, one set of weights and one input shape. Two different checkpoints cannot occupy the same forward pass, so a mixed fleet needs a separate server or pod per policy. Several arms doing different tasks with the same checkpoint is fine, since the task instruction is part of the observation for a vision-language-action model.

Is batching worth it if my GPU is remote?

It depends on the median round trip, not the jitter. Armory swept network jitter at a 50 ms one-way median and found starvation and throughput nearly flat, because the arm's action buffer absorbs late packets. Sweeping the median from 25 ms to 500 ms degraded every scheduler monotonically. Their conclusion is that scheduling cannot compensate for raw transport delay, so fix the path length before you tune the batcher.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started