Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Benchmarks and hardware validation

Two measurement campaigns, both reproduced from the harness in bench/:

  • FR3 benchmark, 2026-09-04franka-sim over loopback plus a real FR3 at 172.16.0.2, against libfranka 0.20.4.
  • Franka Emika Robot (FER) hardware run, 2026-09-05 — two real FERs on system 4.2.1, against libfranka 0.9.2.

The full measurement records for both campaigns are kept privately and are not part of this repository; this page is the public summary of what they found. The hardware runs that followed — the impedance examples, target control, rotation targets and the Python bindings on an FER, target control on an FR3 — are functional checks, not timing campaigns; they are listed in Hardware validation record at the end.

Both were run on the same box: a laptop-class x86-64 CPU with 12 logical cores, Linux 6.8, not PREEMPT_RT. That matters for how to read the tail: every multi-millisecond max in these tables is a scheduling event on a desktop kernel, and these numbers are a same-box A/B, not an FCI qualification of either client.

Both campaigns use the same fairness protocol: a cell is one (condition, variant, repetition), both clients run back to back inside it, the order alternates per repetition, and the simulator container is restarted per cell. The paired within-cell difference is the primary evidence everywhere below; pooled tables are context.

Simulator: loop jitter

Joint-velocity motion, 30 s per run, 3 repetitions, chrt -f 80 + mlockall.

variantclientinterval p50p99lost cyclessuccess avgCPU %
control (callback)C++1000 µs1071 µs0.31.0002.7
control (callback)Rust1000 µs1050 µs0.31.0002.4
active (readOnce/writeOnce)C++1000 µs1046 µs0.01.0003.0
active (readOnce/writeOnce)Rust1000 µs1054 µs0.01.0002.4

Paired over all 18 cells:

statisticC++ minus Rustverdict
CPUmean +0.59 pp, C++ higher in 18/18 cellsreal
interval p99mean −15 µs, median −3 µs, C++ higher in 8/18noise
active recv→send latency p50 / p9917 / 34 µs vs 11 / 23 µs (9 cells)real

The CPU gap holds equally whether C++ ran first (+0.57) or Rust ran first (+0.63), so it is not an ordering artefact. Loop timing is indistinguishable: median cycle time is exactly 1000 µs everywhere and the p99 difference changes sign between variants. Under a 12-core CPU hog, p99 rises to 1.3–1.4 ms (control) or ~1.1 ms (active) for both clients, p50 stays at 1000 µs, and neither client lost a cycle.

The model in the loop

The same operational-space impedance controller in both languages, with all five franka::Model calls evaluated inside every 1 kHz cycle. Simulator, chrt -f 80 + mlockall, 3 repetitions of 30 s.

buildmodel p50p99compute p50interval p50p99lostCPU %
libfranka 0.20.430.8 µs67.033.3 µs1000 µs108405.7
libfranka main (0.21.3)31.2664.4433.961000104905.59
libfranka main + Data-reuse patch11.0727.1513.881000104303.49
franka-rs10.832.912.51000104702.9

Both clients produce the same motion (peak |τ| 1.925–1.940 Nm, peak end-effector excursion 0.04785–0.04789 m), and neither lost a cycle in any of the 12 runs.

The five calls measured offline, on 10 000 identical random FR3 states:

calllibfranka 0.20.4libfranka main+ patch commit 1+ commit 2franka-rs
mass0.670.630.620.610.68
coriolis (state overload)1.931.961.911.861.13
gravity0.570.600.570.580.57
zeroJacobian6.977.170.810.500.32
pose6.346.550.360.370.27
all five16.4816.934.283.922.97

p50 microseconds. The 21–24× gap on pose and zeroJacobian was not a C++/Rust difference: libfranka’s Pinocchio wrapper constructed a whole fresh pinocchio::Data on every kinematics call — 143 heap allocations. A two-commit patch (see patches/) takes that to zero allocations per call and closes essentially the whole gap; the remaining ratio is about 1.3× offline and 1.0× in the loop, and it is not all in the same direction (C++ ahead on mass, franka-rs on coriolis and the Jacobians). Every output is byte-identical before and after the patch, and libfranka’s own 805/805 tests pass on both.

Numerical agreement over the same 10 000 inputs: the largest absolute difference anywhere between the two backends is 4.97e-14 (zeroJacobian 9.99e-16, pose 1.06e-15, mass 3.55e-15). That is double-precision round-off.

FR3 hardware

A real FR3 at 172.16.0.2, model variant, chrt -f 80 + mlockall. Three interleaved repetitions of both clients (stage c) plus one C++ shakedown run (stage b), a read-only Idle-with-no-errors probe before every run, a return-to-ready move before and a 20 s cool pause after, and torque/deviation guards checked outside the timed region.

No guard tripped, no run ended in a ControlException, and 0 reflex events across all 8 runs. The arm reported Idle with no error flags immediately before and after every one.

statistic (stage c, paired, 3 reps)C++ minus Rust
model p50+10.67 µs mean (+9.20 median), C++ higher in 3/3
CPU+2.61 pp mean (+2.01 median), C++ higher in 3/3
interval p991079–1106 µs for every run, both clients — no difference

Representative per-run figures: Rust model p50 12.1–13.8 µs at 2.7–3.6 % CPU against C++’s 21.3–27.6 µs at 4.7–7.4 %; interval p99 1079–1106 µs; success rate average 0.9996–1.0000. The C++ side is unpatched libfranka 0.20.4 — the Data-reuse patch was never built for hardware, so every C++ row still pays the allocation, and would be expected to close the same way it did on the simulator.

FER hardware

Two FERs — L on an onboard NIC, R on a USB Ethernet adapter — on robot system 4.2.1, against libfranka 0.9.2. Thirteen 30 s runs of 1 kHz model-in-the-loop torque control, chrt -f 80 + mlockall.

No reflex, no guard trip, no ControlException, no communication_constraints_violation, and max_consecutive lost cycles was 1 in every run. Both arms were Idle and error-free afterwards.

armclientinterval p50p99lost / 30 000success avg
L (onboard NIC)franka-rs999.4 µs1171.7 µs27.50.9862
L (onboard NIC)libfranka 0.9.2999.5 µs1186.8 µs29.50.9872
R (USB Ethernet)franka-rs999.1 µs1181.1 µs91.70.9873
R (USB Ethernet)libfranka 0.9.2999.1 µs1183.9 µs87.00.9882

Every gap there is smaller than the run-to-run spread of either client. Verdict: for running an FER, neither client is meaningfully better at the job.

Model agreement on the robot, both clients evaluating the same libfcimodels.so at bit-identical inputs: gravity 2.31e−14, coriolis 7.68e−22, mass 1.09e−14, pose 5.27e−15, zeroJacobian 5.27e−15 max absolute difference. Double round-off — the v5 model path is numerically identical to libfranka 0.9.2 on real hardware.

A measurement trap worth knowing about

The campaign’s model_us p50 column read 15.45 µs (Rust) against 5.68 µs (C++) on arm L, and that is not a valid comparison. Offline, on the same shared object and the same inputs, the two wrappers are within 1.2 % of each other (4.011 vs 3.964 µs for all five calls). What the in-loop measurement priced is the CPU’s post-idle frequency ramp: the same C++ code, timed after increasing amounts of untimed filler work, swings from 21.37 µs to 3.98 µs — a 5.4× spread on identical inputs. The tell is that model_us min is 4.0 µs in all thirteen runs for both clients.

The client that does more work between the datagram arriving and the callback starting reaches the model calls on a warmer core. franka-rs’s receive path retires ~1200–1500 instructions where libfranka 0.9.2 retires ~3500–4000, copies 13.4 KB where libfranka copies ~19 KB, and allocates nothing where libfranka does two malloc/free pairs. The crate’s model region measured slower because the crate’s receive path is leaner. No crate change was made; there is no defect. The deadline, lost-cycle, success-rate and guard results are unaffected — those are not sub-10-microsecond measurements.

Where the FER’s lost cycles come from

Wire captures settle this, and the answer is not the client. From tcpdump on the link, including one run stamped with the NIC’s hardware receive timestamps (before the kernel’s interrupt path):

  • Nothing is lost on the network. Every message_id the robot emitted appears in every capture — 0 missing ids in 141 000 states across six runs — and IP reassembly, socket and NIC counters are all zero.
  • The gaps are already on the wire. ~70 inter-arrival gaps ≥ 1.5 ms per 10 s run (about 7 per second), max 4.77–6.91 ms, present in the PHY timestamps. Median inter-arrival is 999.3–999.4 µs, so this is a rare event, not a shifted distribution.
  • The robot’s control loop keeps running. Across each gap the millisecond counter advances by exactly one, and 63–87 % of gaps are followed by a burst draining the backlog: the control box’s transmit path stalls for 2–7 ms a few times per second.
  • The host is excluded. Two SCHED_FIFO stall probes on the NIC IRQ core and another core saw 1 late wakeup in 93 s (and 0 in 13 s during which the wire showed 97 gaps). Disabling the NIC’s Energy-Efficient Ethernet changed nothing.
  • Both clients are excluded. Both drain the socket keeping the newest message_id, so when two states arrive together the older is discarded by design. A “lost cycle” here is a discarded queued state, not packet loss — which is why max_consecutive is never above 1 and the robot’s own packet-loss watchdog is never approached.

The remaining lever is the control box, not this repository.

Arm R loses ~3× more than L on both clients, which tracks its interface, not its client: the USB Ethernet adapter has an RX ring of 100 (vs 256) and rx-usecs of 15000 (vs 3 on the onboard NIC). 15 ms of interrupt coalescing is pathological for a 1 kHz loop.

The v5 state datagram is 2373 bytes, over the 1500-byte MTU, so every state arrives as exactly 2.0000 IP fragments — 2000 packets/s instead of 1000 — with zero ReasmFails, ReasmTimeout, Udp.InErrors or NIC drops. That doubles the packet rate the host must service, which is the most plausible reason the FER loses cycles where the FR3 lost none, but it is a load effect, not a reassembly failure.

Two arms at once

Both FERs driven simultaneously, per arm, 10 000 cycles each:

configurationL lostR lostsuccess avg
two independent processes (taskset -c 3 / -c 5)39290.99 / 0.99
one process, one Robot per thread, 3 runs24–3243–550.98–0.99

No cross-interference. Running both at once does not raise either arm’s loss rate, success rate or interval spread beyond what it shows alone, and both figures match the single-arm campaign. The single-process example (dual_communication_test) gives each thread its own Robot, its own TCP and UDP sockets, and a Barrier that releases both timed loops together; from that point the two robots share no state at all, and if one errors the other runs its cycles to completion.

ActiveControl on the FER

First real-hardware exercise of the read_once/write_once API on an FER. Robot L, 10 s per run:

variantcycleslostinterval p50p99success avgCPU %
active (read_once/write_once)995736999.2 µs1236.60.9812.55
control (callback)997920999.1 µs1271.60.9852.65

No errors, no reflex, Idle after both. Equivalent within noise — the lost-cycle gap is well inside the run-to-run spread seen throughout the campaign. libfranka 0.9.2 has no ActiveControl at all, so on an FER this is a control style the C++ client cannot offer.

Reproducing

cmake -S bench/cpp -B bench/cpp/build -DCMAKE_BUILD_TYPE=Release && cmake --build bench/cpp/build -j
cargo build --release --manifest-path bench/rust/Cargo.toml

bench/run.sh --duration 30 --reps 3      # takes the simulator lock, one container per cell

bench/README.md documents the harness, the --hardware mode and its guards, and bench/fer-capture/README.md the wire-capture tooling. Raw per-run JSON lives under bench/results/.

Hardware validation record

The functional runs on real arms after the two campaigns, in order. Each completed or failed exactly as described; none is a timing measurement.

  • Dual-arm and ActiveControl. Both FERs driven at once, as two processes and as one process with a Robot per thread, with no cross-interference; and read_once/write_once exercised on a real FER, matching the callback API within noise – a control style libfranka 0.9.2 does not offer for that robot generation.
  • Cartesian impedance examples on hardware. cartesian_impedance_active_control and cartesian_impedance_figure_eight ran on a real FER through ActiveControl (2026-09-07), holding and tracking the pose while being pushed, with no reflex.
  • Cartesian pose bridging and the flight recorder on hardware (2026-09-08). nonrealtime_commander ran its full 19 s bridged sequence on a real FER with no reflex (peak commanded speed 0.25 m/s, measured pose within a few millimetres of the command), and its raw mode was refused at the first step and cleared with automatic_error_recovery(); the runs also showed that the robot checks the joint-space continuity of a Cartesian pose stream, which the client-side rate limiter does not bound (see FER / Panda specifics). reflex_replay’s live Recorder pushed 23 941 records at 1 kHz with none dropped, and on every logged cycle the native FER model’s end effector matched the measured O_T_EE to under 0.01 mm.
  • Rotation targets on target control (2026-09-09). nonrealtime_commander --rotate ran its ±15° yaw sweep on a real FER through start_cartesian_target_control, a clean run to the end.
  • Python bindings on hardware (2026-09-09). crates/franka-py/examples/policy_loop.py drove a real FER from an irregular 6-10 Hz policy loop – move_to, move_by and follow chunks – and stop() settled within about 0.3 s of the call. A degraded Ethernet cable shows up as communication_constraints_violation with a clean ping; a packet capture of the 1 kHz stream is what diagnoses it.
  • Target control on an FR3 (FCI v10, 2026-09-09). The bridged and rotation sequences of nonrealtime_commander ran clean, stop() settled and the rate-limiter backstop never bound. The robot’s joint-side acceleration check was bracketed on the same arm: a run whose IK peaked at 9.3 rad/s² passed, two runs were refused with cartesian_motion_generator_joint_velocity_discontinuity in the cycle a joint crossed 10 rad/s² (joint jerk stayed under 1400 rad/s³), so the published 10 rad/s² limit is applied as is.
  • Torque backend of target control on both FERs (2026-09-10). First hardware run of the impedance backend (PREEMPT_RT host, FRANKA_REALTIME=enforce, thresholds 40 N): the commander’s stepped and rotating sequences, joint targets and stops ran with no reflex, the first torque of every session under 0.04 Nm, IK residual under 1e-6, tracking error at the holds 4.6 mm (L) and 2.8 mm (R) at 750 N/m against 3.7 mm for the robot’s own controller on the same sequence. Push tests measured 725 to 1090 N/m felt stiffness, the leash holding the error at 25.0 mm, and 45 to 50 N under a fast push, which trips 40 N thresholds and not 60 N. The numbers are on The impedance backend. The FR3 was not reachable that day.