What the robot does when nobody is asking it to

September 10, 2026 · View on GitHub

A duck sitting on a desk with no pad connected, no viewer, and nothing driving it still runs five daemons. This is what they were each doing in that state, what was changed, and — for the two candidates that were not — the numbers that say why.

Everything here was arithmetic on the code or a measurement on this machine. Only the test pattern has been measured on a board; the last section says what the rest would take.

What was costing something

idle beforeidle after
mediad raw branch1.84 MB copied 30×/scopied when a reader asks — ~2×/s
mediad test pattern720p30 drawn by the CPU — 29.4% of a core256x144@5 — 0.7% of the pixel rate
tofd frame poll~100 I²C reads/s~45
padd, pad connected, sticks centred50–100 msg/s10
pet-detect, when enabled~400 FFTs/s + 4 forward passes/snone until the room makes a sound

mediad copied every frame

The appsink callback copied every buffer off the tee into a slot readers took the latest of. At 720p30 — the shipped quality, and media.source defaults to the camera — a UYVY frame is 1 280 × 720 × 2 = 1.84 MB. The readers are auto-exposure at 2 Hz and the duck detector at 2 Hz when enabled, so twenty-eight of every thirty copies were made for nobody: 55 MB/s of memcpy and a 1.8 MB allocation thirty times a second, from boot.

A reader now asks and the callback answers the next frame. Answering with the last one instead would be cheaper again and would put the reader's own polling period into the measurement — an exposure loop steering on half-second-old luma hunts rather than settles.

The test pattern was drawn at the camera's resolution

A board with [media] source = "test" streams videotestsrc instead, so that a robot with no camera still has a session — signalling, negotiation, the datachannel, the control API, all of which ride the video track. It ran at [media] quality, the same rung a camera streams at.

The difference is where the pixels come from. A camera's frames arrive off the ISP in hardware and cost v4l2src almost nothing to hand on; a test pattern's are drawn by this process, one packed UYVY frame at a time. Two boards, same model, both idle with nothing connected:

sourceidle mediad
Camera, rkisp6.1%
Test, 720p3029.4%

Five times the cost of the thing it stands in for, to synthesise 1.84 MB thirty times a second for a tee whose readers had all said no — camera.json on that board read "frames":23142 against "consumers":0.

The pattern now runs at TEST_PATTERN_GEOMETRY — 256x144 at 5 fps, 16:9 and 8-aligned like every Quality rung, 73 KB a frame. That is 0.7% of the pixel rate, and it is not a compromise: nothing in what the pattern is for wants resolution. media.video reports the small geometry, because that is what the robot is producing.

tofd polled a 15 Hz sensor 100 times a second

data_ready every 10 ms across the whole 66 ms between frames: about seven I²C transactions to find one frame, six of them answered no. The loop now sits out the stretch in which the sensor cannot yet have one and polls through the rest. Frame age is unchanged — still bounded by the 10 ms poll, which is the granularity a frame is noticed at either way.

This runs on every duck with a ToF fitted whether or not anyone uses the theremin: tofd ranges continuously and sends to whoever happens to be subscribed, so no subscriber is the normal state and none of this poll is conditional on one. [theremin] enabled is off by default and does not change the figure — what it saves is robotd's parked read, which was never the cost here.

padd re-sent the same three zeros fifty times a second

The sticks are polled, so an untouched pad produced an identical frame every tick — an encode, a write, a flush, and a parse on robotd's side, doubled in the two modes that also send a zeroing robot.move. Identical frames are now held back, and the two intents share one write.

A frame is only held back when it asks for no motion. That is what keeps this away from [safety] deadman_ms, a value padd cannot read: the deadman zeroes the twist and nothing else, so on a frame already commanding zero, letting it fire changes nothing — and on one commanding motion it would stop a robot whose stick is still held.

The petting classifier ran through silence

A 1 s window of 40 log-mel bands is a hundred 512-point FFTs plus a forward pass, four times a second. The ambient-sound watcher beside it was already measuring the room, so the classifier now waits until something stands out from that floor — except while petting, where the End edge has to be found by inference exactly as the Start was.

Off by default, so this is a cost anyone who turns it on stops paying rather than one every robot was paying.

What was left alone, and why

The 50 Hz bus write is not worth making conditional

safety.apply writes sixteen goal positions every tick, including the ticks where the robot is parked and the targets have not moved. Making that conditional saves:

  • Bus: a Protocol 2.0 sync-write of sixteen servos is 94 bytes — 4 header, 1 broadcast id, 2 length, 1 instruction, 4 address/length, 16 × 5 payload, 2 CRC. At 1 Mbaud, 940 µs. The tick's sync-read of seventeen devices is about 422 bytes, or 4.2 ms, so the write is a fifth of a bus already at roughly a quarter utilisation on a 20 ms tick. It is not the constraint.
  • CPU: one write(2) of 94 bytes and the tty layer behind it. Call it 15 µs including the completion interrupt; at 50 Hz that is 0.075% of one core.

Against that: the write is the loop's one statement guaranteeing the servos have been told where to be. Making it conditional means enumerating every path that can invalidate a servo's stored goal — a torque toggle, a brown-out, a servo rebooting itself after clearing a shutdown latch, init's own interpolate_to — and being wrong means a robot that stops holding its pose in a case nobody thought of. Seven hundredths of a percent of a core does not buy that.

The rest of an idle robotd tick is already lean: the ONNX step is gated on driving, the state frame is only assembled when something has subscribed, the voltage and thermal registers are read once a second rather than per tick, and the serial read sleeps rather than spins.

Multi-threaded tokio runtimes cost nothing to leave alone

robotd, configd and updaterd use bare #[tokio::main], which starts one worker per core — four each on an RK3566. Idle workers park; they do not poll. Measured with a runtime of four workers and four parked tasks, against a current_thread runtime doing the same:

multi:   5 threads   0.01 s CPU at start → 0.01 s after 20 s
current: 1 thread    0.00 s CPU at start → 0.00 s after 20 s

The difference is four parked threads' worth of stacks — memory, not heat. Switching flavours would buy none of the CPU this page is about, and would cost something real: on current_thread, a synchronous call on the async path blocks the whole runtime including the IPC socket, and updaterd has several (engine.rs reads a signature, writes the embedded manifest, and scans a unit directory inline; only the heavy verification is on spawn_blocking).

What still needs a board

Every figure above is arithmetic on frame sizes, packet layouts and poll intervals, or a measurement on a developer's machine. The four changes want confirming where the heat actually is:

  • mediad, tofd and padd CPU before and after, from dev-push.sh and top -H. The camera one should be the visible change. What exists so far is mediad idle on 0.12.0 — 6.1% with a camera, 29.4% with the 720p test pattern — which is an after-figure for the raw branch with no before-figure beside it, and nothing at all for tofd or padd.
  • The test pattern at 256x144@5 on the board that measured 29.4% at 720p30. The arithmetic says 0.7% of the pixel rate; what it actually leaves is videotestsrc's per-frame overhead, which no longer scales with the picture.
  • SoC temperature at idle over ten minutes, which is the number the whole exercise is for. The videoflip episode took this board to 97 °C and throttled it to 408 MHz, so idle headroom is what decides whether a duck walks well while it is also looking at something.
  • tofd's guard against a real sensor clock. The poll is anchored on each frame's arrival and tolerates the sensor running 20 ms early — a 30% period error — but no VL53L8 has been watched doing it.
  • That petting still starts as promptly as it did, on a robot in an ordinary room rather than a silent one.

What came after

The head sensors were the loose end here, and a ps -L on a board settled them: tofd's idle ~5% is 4.5% head IMU and 0.5% depth, and the IMU has no consumer in the tree at all. So the poll this page made cheaper was never the cost, and the thing worth turning off is the sensor nobody asked for. See tof-on-demand.md.