Orbit C++ → Rust · Post 06

The Gate Says No

Phase 3 ported two classes, benchmarked both, and stopped — because the numbers said to, and the whole point of writing the gates down in advance was to obey them when they do.

2Classes ported
~20%Slower: merge queue
2.4×Slower: state machine
100%Checksum agreement
0Defaults flipped
5Classes not ported

Phase 2 ended with LLVM deleted and every number pointing the right way. Phase 3 — the seven pure-logic classes in LinuxTracing — was supposed to be the easy encore: excellent test coverage, no I/O, algorithms with the C++ as their own specification. It is instead the phase where gate 4 finally fired, and this post is the write-up of a stop.

What got ported, and how well

Two classes, chosen from opposite ends of the FFI-shape spectrum on purpose.

PerfEventQueue — the heap-of-queues timestamp merge. PerfEvent is a large C++ variant that must never cross the boundary, so the Rust side owns only (stream, timestamp, handle) keys and the facade keeps events in a stable-reference slab the handles index. The hand-written heap was replicated comparison for comparison — that code is the spec — with one documented strengthening: equal-timestamp unordered events pop FIFO where std::priority_queue leaves the order unspecified.

ThreadStateManager — the per-thread sched state machine. Pure integers in, one small struct out; no slab, no handles, the cheapest FFI shape there is. Every quirk reproduced deliberately: stale initial states replaced by older tracepoints, wakeups of runnable threads disregarded, runnable adjusted to running on switch-out, no callstack status on a switch-out slice. The crate returns warnings as data and the facade owns the ORBIT_ERROR strings, so even the log lines stay byte-identical.

Correctness held everywhere it has all along: all three backend modes pass the unedited test suites, the sabotage checks fail exactly the targets they should, and every benchmark run of both backends produced identical checksums — the implementations pop the same events and emit the same slices, always.

And then the benchmarks

ClassFFI shapecpprust
PerfEventQueue slab + handles, 3 calls/event 63–72 ns/event 77–79 ns/event ~15–20% slower
ThreadStateManager integers only, 1 call/event 11.8–12.8 ns/transition 30.3–33.3 ns/transition ~2.4× slower

Two optimisation rounds went into each before those numbers settled: an FxHash-style hasher replacing SipHash (thread ids do not need DoS resistance), entry-API single-hash pushes, O(1) stream removal, a C++-side size counter making HasEvent free, a cached top handle. They moved the merge queue from 27% slower to 20% and the state machine from 3× to 2.4×. The floor underneath is not optimisable from inside:

where a transition's nanoseconds go · ThreadStateManager cpp map lookup + logic, inlined ~13 ns rust opaque call 48B copy the same logic ~31 ns The toll — a call that cannot inline across languages, plus marshalling — is roughly fixed per event. Against the merge queue's 63 ns of real work it costs ~20%. Against the state machine's 13 ns it costs 2.4×. The five unported Phase 3 classes — ContextSwitchManager, UprobeAddressMap, UprobesFunctionCallManager, UprobesReturnAddressManager, LeafFunctionCallManager — are all smaller per call than these two. The trend line does not need five more measurements.
The relative toll grows as the function shrinks. Phase 2 never felt this because its unit of work was a file, not an event: one FFI call per ELF parse amortises to nothing. Phase 3's unit of work is a tracepoint, and there is no amortising a boundary you cross millions of times a second.

The verdict, and why it is the plan working

The plan wrote this gate down before any of Phase 3 existed: "correctness first, performance later is how a port quietly becomes a regression" — and, about this exact phase: "this is the one place where a Rust port could plausibly be slower — an FFI call per event is not free." The original analysis called for a measured experiment before any commitment. This was it.

What could reopen it

Two things, both named in the verdict file. Cross-language LTO — clang with linker-plugin LTO can inline Rust into C++ callers, which would erase most of the fixed toll; the CI toolchain is gcc today. Or moving the language boundary to where the original analysis wanted it all along: the wire format. A Rust collector speaking the protocol has no per-event FFI, because there is no C++ on the other side of the call. Rust islands inside a C++ pipeline were always the second-best shape; now there is a number on exactly how much second-best costs.

The port, closed out

PhaseOutcome
1 — ParseMapsPorted, default Rust, 1.75× faster, zero dependencies
2 — ObjectUtilsFully ported, LLVM deleted, cold build −34%, verified against 16.3M symbols
3 — LinuxTracing pure logicTwo classes ported and validated; gate 4 measured both slower; defaults stay C++; five classes not ported, by the numbers

A log that only ever reported green would be advertising. The gates were written down when everything was going well precisely so that this post could be short, specific, and final: the merge queue and the state machine work, they are provably equivalent to the C++, and they are slower, so the C++ keeps the job. Six posts in, that is the first sentence of its kind — and the instruments that produced it are the same ones that justified deleting LLVM.