Orbit C++ → Rust · Post 07

Overruled

Post 06 stopped Phase 3 on the performance gate. The project owner overruled it — port everything, accept the toll, fix it later — and that decision deserves the same documentation discipline as the gate did.

5Classes on Rust by default
3Newly ported
~5–9 nsToll per FFI call
~2%Of a core, worst case
100%Checksum agreement
1Env var to roll back

A gate is advice with numbers attached; who accepts the trade is a project decision, not the porter's. The decision here was explicit: one implementation language is worth a measured per-event cost, provided the cost is written down, an attempt is made to shrink it now, and the path to removing it is named. All three conditions are met in this post.

What is now on Rust by default

ClassSwitchAccepted cost
PerfEventQueueORBIT_PERF_MERGE_BACKEND63–72 → 77–79 ns/event
ThreadStateManagerORBIT_THREAD_STATES_BACKEND~13 → ~31 ns/transition
ContextSwitchManagerORBIT_TRACING_STATE_BACKEND~22 → ~36 ns/in-out pair
UprobesFunctionCallManagerORBIT_TRACING_STATE_BACKENDsame toll shape
UprobeAddressMapORBIT_TRACING_STATE_BACKENDsetup-time, one lookup per sample

The three new ones live in one crate, orbit-tracing-state, and every quirk travelled with them — including one the C++'s own comment gets wrong: ContextSwitchManager's comment says a stale switch-in is overwritten, but the code uses emplace, which keeps the existing entry. The code is the spec; the Rust keeps the first entry, and there is a test pinning it.

All the machinery from previous phases applies unchanged: three-way switches with both comparing against the C++ twins, test files unedited in dual-backend targets, sabotage checks (an off-by-one in address resolution fails exactly the Rust-exercising targets), and identical checksums in every benchmark run. One suite taught a small lesson: ContextSwitchManagerTest's death test greps for the literal text timestamp_ns >= open_timestamp_ns, so the facade's death message carries that expression verbatim.

The cost, priced in capture terms

The toll is ~5–9 ns per opaque cross-language call — the call cannot inline, and the result struct crosses through memory. Per event that is one call (state machine, in-switch) to three (the merge queue's push/top/pop). Priced against a heavy capture:

1,000,000 sched events/s  ×  ~18 ns extra  ≈  18 ms/s  ≈  2% of one core

That is the number the decision accepted. It is real, it is bounded, and it is honest to state that the shrinking attempts made now — an FxHash map, entry-API single-hash pushes, a facade-side size counter, a cached top handle — moved the totals from ~27% and ~3× down to the figures above and no further. The floor is the boundary itself.

What is still C++, and the different reason why

UprobesReturnAddressManager and LeafFunctionCallManager are not ported, and not because of nanoseconds: both are welded to libunwindstack — MapInfo, Maps, the unwinder itself — which the original analysis kept behind its existing C++ interface by design. That is a dependency boundary, not a performance one, and overruling a performance gate does not overrule it. Porting half of each class around the libunwindstack calls would produce the worst of both worlds.

The "later" this decision points at

Until one of those lands, the C++ twins stay in tree behind their switches, and rollback is one environment variable per class. The gate lost the argument; the numbers it produced are why the argument was worth having.