The inference server¶
Mote keeps torch off the robot. The heavy vision models (monocular depth today, open-vocabulary detection now, SfM/policy inference later) run in a separate process reached over a plain TCP socket, so the robot's ROS environment stays light and the compute can live wherever the GPU is. This document is about running that process as a first-class role on a dedicated machine that the whole robot talks to, and that the robot degrades gracefully without.
The mechanism (the two-process split, the depth_wire/detect_wire protocol,
the on-robot nodes) is described in mote_perception/README.md.
This document adds the deployment: how it ships, how it starts, how it updates,
health, the multi-service pattern, the fallback matrix, and how to measure it.
It ships as a container¶
The inference machine is not a development environment. It is a Windows gaming PC, or a Linux box, or a rented cloud GPU — a machine whose owner should not have to install a toolchain, clone a repo, or run a setup script to lend the robot its GPU. So the server ships as a container image and the host needs exactly two things: an NVIDIA driver and a container runtime.
That also makes the role portable in a way the alternatives were not. The same image is the answer for a contributor with no Windows PC who wants to rent a cloud GPU for an afternoon, and for a permanently-installed box on the LAN.
Host options considered:
| Container (chosen) | Native Windows + pixi | WSL2 + pixi | |
|---|---|---|---|
| What the host installs | ✅ a container runtime | ❌ pixi, a git checkout, PowerShell scripts | ❌ pixi, a checkout, plus a WSL distro |
| GPU access | ✅ --gpus all (Docker Desktop bundles the NVIDIA toolkit; on Windows this runs over WSL2, but as an installer detail you never touch) |
✅ direct | ✅ via the WSL2 CUDA driver |
| Reaching it from the robot | ✅ -p 5601:5601 publishes on the host interface |
✅ binds the LAN directly | ⚠️ NAT'd — needs portproxy or mirrored networking |
| Works on a cloud GPU | ✅ same artifact, no changes | ❌ Windows-only | ❌ awkward |
| Update | ✅ docker pull |
❌ git pull + re-solve on the host |
❌ same |
| Reproducibility | ✅ a frozen, versioned image | ⚠️ pixi.lock pins deps, but the host supplies the rest |
⚠️ same |
| Cost | ⚠️ a multi-GB image to build in CI and pull once | ✅ none beyond pixi | ✅ none beyond pixi |
The native-pixi paths were implemented first and then removed: they required the inference machine to become a small dev environment, which is exactly what the owner of a gaming PC does not want. The container's one real cost — a few GB of image, built in CI and pulled once — buys a host that stays a gaming PC.
pixi is still how inference runs for development on Linux (pixi run
inference, pixi run inference-rocm). The container is how it runs as a
deployed role. Those are different jobs and it is fine that they use different
tools; nothing on the robot changes either way.
Setup: one command¶
On the inference machine, install a container runtime with GPU support — on Windows that is Docker Desktop (free for personal use; tick Start Docker Desktop when you log in), on Linux Docker plus the NVIDIA Container Toolkit. Then, once:
docker run -d --gpus all --restart unless-stopped \
-p 5601:5601 -p 5602:5602 \
--name mote-inference \
ghcr.io/clachdev/mote-inference:latest
That is the entire deployment. No repo, no scripts, no files on the host.
--restart unless-stopped means it comes back whenever the Docker daemon starts,
so it survives reboots and crashes on its own.
Confirm from the robot:
Expect depth UP and detect UP with the GPU name and the image's version.
Windows: boot vs login. Docker Desktop is a desktop application — it starts when you sign in, not at boot. After a reboot the container comes back once someone logs into the PC. For a personal machine that is normally fine; if you ever need it up before login, that needs Docker Engine inside WSL2 started by a scheduled task, which puts a setup script back on the host.
On-demand GPU: the model is not resident when idle¶
A gaming PC should not have ~1 GB of VRAM pinned by a robot that is parked, so the
servers load their model on the first request and release it after
--idle-timeout seconds (default 300) with no traffic. Serving continuously
never unloads; serving after an idle period pays a few seconds for the reload.
This lives in the server (tools/model_host.py),
not in the deployment, so it behaves identically on a gaming PC, a Linux box, and
a cloud instance — no socket activation, wake proxy, or orchestration. The health
blob reports loaded, so "up but cold" is a visible, normal state rather than
something that looks like a hang.
To keep a truly dedicated box always-resident, pass --idle-timeout 0:
Arguments after the image name pass through to both servers.
Updating¶
The manual version is two commands and a re-run, or the equivalent buttons in Docker Desktop's GUI:
docker pull ghcr.io/clachdev/mote-inference:latest
docker rm -f mote-inference
docker run -d --gpus all --restart unless-stopped -p 5601:5601 -p 5602:5602 \
--name mote-inference ghcr.io/clachdev/mote-inference:latest
That deploys whatever it pulled, though — including a build that starts, listens,
and cannot infer. deploy/inference-deploy.sh
is the same thing with a gate in front of it:
./inference-deploy.sh up # first deploy
./inference-deploy.sh update # pull, verify on a shadow port, cut over
./inference-deploy.sh rollback
./inference-deploy.sh status
update starts the candidate as a second container on ports 5611/5612 while
the current one keeps serving, and makes it prove itself with
tools/probe.py — the health sentinel
and a real synthetic frame through both services. The frame is the point: a
health check is answered before the model has ever loaded, so it cannot see a
broken weight download, a CUDA/driver mismatch, or a torch build that faults on
the first forward pass. A candidate that fails is removed and the update aborts
with the running version untouched; one that passes takes the served ports, is
probed again, and is rolled back automatically if that fails.
It is one file, curled onto the host once, and it stays one file — every check
runs inside the image being deployed, so the GPU box still installs nothing.
The full rationale, including why the cutover is a stop-then-start rather than a
port flip pushed out to the robots, is in
docs/fleet/server-pipelines.md.
CI (inference-image.yml) builds and
pushes to GHCR when the server files change on main, on a tag, or on demand;
latest tracks main and sha- / version tags let a host pin a known-good build
and roll back.
Version skew is visible, not silent. The robot and the server share the wire
protocol modules, so they must move together. CI bakes the build's revision into
the image as MOTE_VERSION, each server reports it in its health blob, and
pixi run inference-health compares it against the robot's own:
inference host: mote-gpu (this machine: a1b2c3d)
depth UP ... @ v0.1.0-42-g6aa919f
detect UP ... @ v0.1.0-42-g6aa919f
WARNING: version skew — this machine is at a1b2c3d, but depth is at ...
In practice the protocol changes rarely and additively — the health request was
added without breaking existing clients, since an unknown leading uint32 could
never be confused with an image length — but when it does change, this turns a
confusing protocol error into a one-line diagnosis.
Scaling to a cloud GPU¶
The image runs unchanged on a cloud GPU instance, which is the practical option for anyone without an NVIDIA machine. Two things change, and both matter:
1. The wire protocol is unauthenticated and unencrypted. That was a
deliberate choice for one hop on a trusted LAN, and it is documented as such in
depth_wire.py. On a public cloud instance, publishing 5601/5602 means anyone
who finds the port can use your GPU and read the images your robot sends. The
tailnet is what makes "trusted network" true again off-LAN — but on a rented box
the safest shape is to put the container itself on the tailnet and publish
nothing on the host, using a Tailscale sidecar:
# docker-compose.yml — the inference container has no ports of its own; it shares
# the sidecar's network namespace, so it is reachable *only* over the tailnet.
services:
tailscale:
image: tailscale/tailscale:latest
environment:
TS_AUTHKEY: ${TS_AUTHKEY} # tagged, e.g. tag:inference
TS_HOSTNAME: mote-inference # becomes the MagicDNS name
TS_STATE_DIR: /var/lib/tailscale # persist, or every restart makes a new node
TS_USERSPACE: "true"
volumes: [ts-state:/var/lib/tailscale]
restart: unless-stopped
inference:
image: ghcr.io/clachdev/mote-inference:latest
network_mode: service:tailscale # no `ports:` — nothing on the host
deploy:
resources: {reservations: {devices: [{driver: nvidia, count: all, capabilities: [gpu]}]}}
restart: unless-stopped
volumes: {ts-state: {}}
This is stronger than binding to the tailnet address by hand: there is no
host-published port to get wrong, no bind-ordering race at boot, and the instance
never has to be permanently enrolled — the container is the tagged
tag:inference node with its own MagicDNS name, which is exactly the identity
model M0 defines. Set inference_host to TS_HOSTNAME and the robot is
unchanged. (The same pattern works on a Linux GPU box; on a personal Windows PC
the simpler host-Tailscale route above is usually the better trade.)
Adding a shared-secret token to the handshake would be the next step if the socket ever needed to face a hostile network directly; it is deliberately not built, because the tailnet solves the problem without complicating a protocol whose value is that you can debug it with a hexdump.
2. Bandwidth, not latency, is the limit. Each frame sends ~50 KB up but the
depth reply is 640×480×4 = 1.2 MB down. On a LAN that is nothing; over
broadband it dominates the round trip. Note that the pipeline stamps clouds at
image-capture time and Nav2 places them via tf, so added latency costs
rate, not correctness — obstacles land in the right place, just less often.
If cloud use becomes routine, sending float16 depth would halve the payload for
no meaningful precision loss at these ranges; pixi run inference-bench measures
exactly this, so the decision can be made on numbers.
This is why docs/design/fleet.md sets the placement rule: keep the inference
server on the same LAN as the robots it serves, and treat the tailnet as what
makes an occasional cross-site fallback possible rather than the normal path.
Per-robot inference_host already expresses that — each robot points at its
local GPU box.
Multi-service pattern (adding the next tenant)¶
Depth and detect are already two tenants, and a third (SfM, a policy server, …)
is a config exercise, not a redesign. The seam is
inference_server.py, the
supervisor the container runs:
To add a tenant:
- Write the server in
mote_perception/tools/, followingdepth_server.py: load the model through aModelHost(so it inherits on-demand loading),listen(1), and in the per-connection loop read the leadinguint32— if it equalsHEALTH_MAGIC,send_health(conn, info)and continue; otherwise read your request and reply. - Give it a wire module (
mycompute_wire.py) with aDEFAULT_PORT(next free port, e.g. 5603), the request/reply framing, and aClient(WireClient)subclass — it inheritsconnect/close/health/reconnect for free. - Add one row to
SERVICES, plus its port to theEXPOSEline and the documenteddocker run. It now inherits binding, supervision, restart, on-demand loading, and the health probe. - On the robot, add its node to
perception_launch.pyand amycompute: { enabled, server_port }block toperception.yaml, exactly likedepth/detect.
Port allocation is manual and documented here (5601 depth, 5602 detect, 5603+ next) — a fixed small map beats a discovery protocol for a handful of services.
Pointing the robot at the server¶
The single deployment knob is inference_host in
mote_perception/config/perception.yaml:
inference_host: mote-gpu # the inference machine
depth: { enabled: true, server_port: 5601 }
detect: { enabled: true, server_port: 5602 }
Override per-robot in $MOTE_HOME/perception.yaml (~/.mote by default), the
per-robot state root M0 formalised — same precedence as the camera calibration,
resolved through mote_bringup.mote_home.override() so an update can never
clobber it. pixi run inference-health resolves the same way, so the probe always
reads the file the nodes were launched with. This lives here, not in robot.yaml:
robot.yaml is shared hardware description, perception.yaml is perception
runtime (and $MOTE_HOME/robot.yaml is a third thing again — this robot's
identity). No discovery
protocol is invented — a stable hostname is the contract.
Use the machine's MagicDNS name. Since M0 every robot, workstation and GPU box
joins one Tailscale tailnet (docs/fleet/README.md), so inference_host should be
the inference machine's tailnet name rather than a LAN IP — that is what
docs/design/fleet.md specifies, and it means the name stays correct whether the
robot reaches the box over the LAN or from another site. It also removes the need
for a DHCP reservation or a hosts entry.
The tailnet and the container¶
Tailscale runs on the host; the container does not need to know it exists. Docker publishes the ports onto the host's interfaces, Tailscale gives the host a MagicDNS name, and the robot connects to that name — nothing in the image, the protocol, or the perception code changes. Three practical points:
1. pixi run tailnet is Linux-only. mote_bringup/tailscale/install.sh is
curl | sh plus systemctl, so it covers robots, a Linux GPU box and the fleet
server — not a Windows PC. There, install the Tailscale Windows app and sign in;
the result is the same tailnet node.
2. Leave a personal PC an untagged workstation. The roles script offers
--role inference (tag:inference), but advertising a tag transfers the node
from your account to the tailnet — the M0 runbook already makes this call for a
co-located dev machine, and it applies at least as strongly to a gaming PC you
own. Nothing functional is lost: robots reach it by MagicDNS either way and
inference_host is just a name. What you defer is M7's ACLs keying on
tag:inference rather than your user. Tag it when the box outlives your account.
3. -p 5601:5601 publishes on every interface, not just the tailnet. On a
home LAN behind a router that is the same trusted-network posture the wire
protocol already assumes, so it is fine. To restrict it to the tailnet, bind the
published port to the Tailscale address:
docker run -d --gpus all --restart unless-stopped \
-p 100.x.y.z:5601:5601 -p 100.x.y.z:5602:5602 ...
Note the ordering hazard: after a reboot Docker can start before Tailscale has
assigned that address, and the bind fails. --restart unless-stopped retries
with backoff so it recovers on its own, but if that bothers you, the sidecar
pattern below avoids the problem entirely by never publishing on the host.
Fallback matrix (server present / absent)¶
The robot must keep working when the inference machine is off, asleep, or
unreachable. It does: the depth/detect nodes are torch-free and treat "no server"
as "skip this frame", never as a fatal error. Navigation runs on lidar; the
camera obstacle layer is an additive near-band voxel layer, so losing it
degrades obstacle coverage but never stops nav. It also fails clean: the layer is
a spatio_temporal_voxel_layer, whose marks expire voxel_decay seconds (5 s)
after they were last observed, so a stream that stops leaves the local costmap
back on lidar alone rather than frozen with whatever the camera last saw.
| Situation | What runs the model | /camera_obstacles |
Navigation |
|---|---|---|---|
| Inference machine up | NVIDIA CUDA in the container — the fast path | published normally | full: lidar + camera near-band |
| Machine down / unreachable / not logged in | nothing — node warns (throttled 2 s) and skips each frame; publisher stays alive | silent (no points) | unaffected — camera marks decay out within 5 s, nav runs on lidar alone |
| Model idle-released | reloads on the next frame (a few seconds) | brief gap, then normal | unaffected |
| No GPU box at all | dev fallback: pixi run inference-rocm (AMD iGPU) or pixi run inference (CPU) on a Linux machine |
published (slower) | full, at reduced depth rate |
The "down" case is warn-and-skip, not disable: DepthClient.infer /
DetectClient.infer return None on any socket failure and the node returns
early, so the topic goes quiet and resumes automatically when the server returns
— no relaunch. Verified by test_depth_client_unreachable_returns_none_and_warns
and the reconnect test.
To intentionally disable a service, set depth.enabled: false /
detect.enabled: false in perception.yaml and relaunch pixi run perception.
Leaving one enabled with no server is harmless.
Measuring it¶
Two committed harnesses; results live under
mote_perception/benchmarks/.
pixi run inference-bench— client-side, torch-free, run from the robot or any machine that can reach the server. Times the full round trip the node pays — compress → send → infer → receive — so the number includes GPU time and the network hop:
pixi run inference-bench --host mote-gpu --image sample.jpg --frames 200 \
--out mote_perception/benchmarks/depth_cuda_lan.json
Prints a percentile table (min/p50/mean/p90/p99/max ms + fps) and writes the raw
samples as JSON. --service detect --labels "red box" for the detector. Note
the first timed frame after an idle release includes the model load — use
--warmup (default 5) to exclude it, or --idle-timeout 0 on the server.
- The server's per-frame log (
served WxH in N ms) isolates model time, so the bench round trip minus that is transport overhead.
See benchmarks/README.md for the
baselines and how to fill in results.