Orbit C++ → Rust · Post 01

The First 73 Lines

Porting the smallest real function in the tree, to find out whether the machinery works before betting anything on it. It did — and it found two bugs, one of them mine.

1.75×Faster, median of 5
11New Rust tests
4/4C++ tests, both backends
0Dependencies added
0Test lines edited
8Hurdles worth writing down

Orbit's capture backend is 34,377 lines of C++. The plan for moving the parts of it that benefit from Rust is written up separately; this is the first step actually taken. It is deliberately tiny — one pure function, 38 lines of implementation — because the point of step one is not to port anything valuable. It is to find out whether three C++ test targets can run against a Rust implementation, and whether a deliberately broken Rust implementation makes them fail.

What got ported

orbit_module_utils::ParseMaps reads the text of /proc/[pid]/maps and returns one struct per mapping. Orbit calls it for every process in the process list, and the process list is polled, so it is genuinely on a hot path while also being trivially testable: text in, five integers and a path out, no I/O, no failure mode.

// src/ModuleUtils/include/ModuleUtils/ReadLinuxMaps.h — unchanged
[[nodiscard]] std::vector<LinuxMemoryMapping> ParseMaps(std::string_view proc_pid_maps_content);

That signature does not change, and neither does any of its callers. What changes is that the definition now lives in a new Backend.cpp that dispatches on ORBIT_MAPS_BACKEND to one of three paths: the original C++, the new Rust, or both, comparing the results and aborting if they differ. The original C++ is renamed ParseMapsCpp and otherwise untouched.

The gate, and proving it is real

Three cc_test targets are generated from one set of attributes by a small Starlark macro. They share srcs, deps and data by construction and differ only in one entry of env, so a suite cannot drift between backends.

$ bazel test //src/ModuleUtils:all //rust:all
//src/ModuleUtils:ReadLinuxMapsTests            PASSED
//src/ModuleUtils:ReadLinuxMapsTestsRust        PASSED
//src/ModuleUtils:ReadLinuxMapsTestsBoth        PASSED
//src/ModuleUtils:ModuleUtilsTests             PASSED
//rust:orbit_maps_test                         PASSED
//rust:orbit_maps_ffi_test                     PASSED
Executed 6 out of 6 tests: 6 tests pass.

A green test suite proves nothing on its own. If the environment variable were not reaching the code, or the comparison never ran, all three would pass exactly like this. So before believing any of it, I broke the Rust on purpose — a one-character change, inode becoming inode + 1, the kind of difference no type system catches:

$ sed -i 's/^            inode,$/            inode: inode + 1,/' rust/crates/orbit-maps/src/lib.rs
$ bazel test //src/ModuleUtils:ReadLinuxMapsTests{,Rust,Both}

//src/ModuleUtils:ReadLinuxMapsTests            PASSED in 0.3s
//src/ModuleUtils:ReadLinuxMapsTestsRust        FAILED in 0.3s
//src/ModuleUtils:ReadLinuxMapsTestsBoth        FAILED in 0.9s

Fatal: ParseMaps backends disagree at entry 0:
  cpp:  0x400000-0x452000 perms=0x5 offset=0 inode=173521 path="/usr/bin/dbus-daemon"
  rust: 0x400000-0x452000 perms=0x5 offset=0 inode=173522 path="/usr/bin/dbus-daemon"

That is the result I wanted: the C++ target is unaffected, proving the original path is genuinely untouched; the Rust target fails on the assertion; and the comparing target aborts with both values printed. The gate works. Everything after this rests on that one experiment, so it is worth re-running whenever the machinery changes.

Why it is 1.75× faster

I did not expect a speedup from a string parser and was ready to report a regression. The measured result, median of five alternating rounds so a warming CPU cannot favour whichever runs first:

$ scripts/bench_parse_maps.sh 30000 5
iterations   30000, 5 rounds
cpp          8.222 us/parse (median)
rust         4.700 us/parse (median)
speedup      1.75x

The reason is not that Rust is fast. It is that the two versions have completely different allocation profiles, and that is visible in the source rather than inferred from the number.

C++ — absl::StrSplit returns owning strings string_view in vector<string> of lines whole input copied, 1 alloc/line vector<string> of 6 tokens each line copied again vector<string> start/end address field copied again Roughly 9 heap allocations per line, none of which outlive the loop iteration. For a typical 42-entry maps file that is several hundred allocate-copy-free cycles per call, to produce 42 structs. RUST — every split borrows &[u8] in split() over &[u8] subslices — 0 allocations Vec<&[u8]> of 6 tokens 1 small vec, pointers only output Vec + 1 path each the only lasting allocations The borrow checker is what makes this the easy way to write it. Returning a slice of the caller's buffer is safe by construction, so there is no temptation to copy defensively — which is exactly the temptation absl::StrSplit into vector<string> is giving in to. The speedup is a lifetime-safety feature showing up as a performance number.
Same algorithm, different memory behaviour. absl::StrSplit can produce string_views too — the C++ could be rewritten to borrow. The point is not that it is impossible in C++; it is that the safe-by-default version in Rust is also the fast one, so the fast version is what gets written without anyone deciding to optimise.

The evidence, including what got worse

Two of these numbers move in the wrong direction. That is expected at this stage and worth showing rather than hiding: the plan deliberately keeps both implementations in the tree until a whole phase is proven, so mid-port the tree is strictly bigger.

MeasureBeforeAfterReading
ParseMaps speed 8.222 µs 4.700 µs 1.75×, median of 5 alternating rounds
External dependencies of the new code 4 Abseil targets 0 ascii, numbers, str_format, str_split → orbit-maps has an empty [dependencies]
Error handling n/a here, ErrorMessageOr elsewhere Option The two new parse helpers return Option<u64>. Nothing was written to express "might not have a value"
Test lines edited — 0 ReadLinuxMapsTest.cpp is byte-identical. This is the rule that makes the gate mean anything
Tests covering ParseMaps 4 4 C++ × 2 backends + 11 Rust The Rust tests mirror the C++ assertions one for one, then add cases the C++ never had
deps(//src/ModuleUtils) 5,850 5,906 Worse by 56. The Rust toolchain is now in the graph. This does not pay off until LLVM leaves in Phase 2
ModuleUtils implementation lines 557 694 Worse by 137. Both implementations plus a dispatcher, on purpose. The C++ is deleted at the end of a phase, not the start

The honest summary of the dependency line: a port that keeps the old code cannot show a dependency win until it stops keeping the old code. What it can show now is that the new code has no dependencies at all, which is the thing that will eventually make the graph smaller.

Eight hurdles

01 · env

cargo existed but was not on PATH

which cargo returned nothing, which looked like "no Rust toolchain" and nearly sent me down the path of doing every iteration through Bazel. ~/.cargo/bin was there the whole time and simply not exported. Thirty seconds lost, but worth recording because the failure looks identical to a much bigger problem.

02 · semantics

/proc paths are not guaranteed UTF-8

The obvious signature is parse_maps(&str). It is wrong. The C++ takes a std::string_view — bytes — and a path in /proc/[pid]/maps is whatever the kernel recorded, which need not be valid UTF-8. Taking &str would have added a failure mode the original does not have, on input Orbit does not control.

So the crate works on &[u8] throughout and pathname is a Vec<u8>. There is a test for it. This is the kind of thing that would have shipped silently and then failed on one user's machine.

03 · my bug

Vec::with_capacity(usize::MAX + 1)

My split helper reserved max_splits + 1, and I called it with usize::MAX to mean "unbounded" when splitting the address range. Every test panicked instantly with attempt to add with overflow.

The fix was to stop abusing the helper and use a plain split() iterator for the address field, plus a saturating, capped reservation so a caller-supplied bound can never overflow. Worth noting what happened here: the bug was in the new code, and Rust's debug overflow check found it in the first test run, in 0.00 seconds. In C++ this is reserve(SIZE_MAX), which is a bad_alloc at best and a silent wrap at worst.

04 · my other bug

The overflow test did not test overflow

I wrote parse_maps(b"00000000000000017-1 …") expecting a rejected line, on the theory that 17 hex digits cannot fit in a u64. Seventeen zeros are worth zero. The test failed, correctly, and the code was right all along.

A small embarrassment, but the useful lesson is that it was the test that was wrong and the test suite said so. Had I written the assertion loosely enough to pass, I would have shipped an untested overflow path.

05 · divergence

std::stoull throws; the Rust skips the line

The C++ parses addresses with std::stoull(s, nullptr, 16), which throws std::invalid_argument on a string with no leading hex digits, and silently ignores trailing garbage. No test covers either case, because a real /proc/maps never contains malformed hex.

The Rust returns None and skips the line, consistent with how every other malformed field in the same function is already handled. This is a deliberate, documented behaviour change, and it is the one place in this commit where the two backends are not equivalent on all possible inputs — they are equivalent on all inputs the kernel produces. A parser reading kernel-supplied text should not be able to terminate the process, and the C++ currently can.

06 · fidelity

absl::MaxSplits does not collapse repeated separators

absl::StrSplit(line, absl::MaxSplits(' ', 5)) yields at most six fields, and the sixth keeps every remaining space — which is exactly why the C++ then calls StripLeadingAsciiWhitespace on it. Getting this wrong in either direction breaks paths that contain spaces, which the test suite specifically checks with /path with spaces.

Rust's splitn is close but not identical in the edge cases, so the crate has its own split_max with a test pinning the behaviour, including b"a b" producing an empty middle field. Reproducing a library's exact semantics is most of the work in a port like this, and it is invisible in the diff.

07 · build

The obvious dependency edge is a cycle

The Rust shim implements ParseMapsRust and therefore needs LinuxMemoryMapping from ModuleUtils's headers. ModuleUtils needs the shim to dispatch to it. Bazel, correctly, refuses.

The fix is a headers-only ModuleUtilsHeaders target that both depend on. It is a two-minute change here and worth knowing about now, because the same split is needed for ObjectUtils in the next phase, where the header set is much larger.

08 · api

ORBIT_CHECK_MSG does not exist

I wrote the comparison using a check-with-message macro that every codebase seems to have. Orbit has ORBIT_CHECK(assertion) and ORBIT_FATAL(format, ...) and nothing that combines them, so the comparison is an explicit if plus ORBIT_FATAL. Trivial, but a reminder that a port touches the host codebase's conventions as much as the language.

What this does and does not prove

It proves the machinery: a Rust static library links into Orbit's Bazel build, three test targets run one suite against two implementations, the comparing mode genuinely compares, and a broken Rust implementation fails loudly and specifically. It also gives the first real data point on speed, with a mechanism behind it rather than a vibe.

It does not prove anything about the parts that matter. ParseMaps is 38 lines with no error type, no lifetime that outlives a call, and no dependency on anything. The next step is ObjectUtils — ELF, DWARF, COFF and PDB parsing, 66 existing test cases, and the only thing in the tree that depends on LLVM. That is where the port either pays for itself or does not.

Everything here is reproducible: scripts/port_metrics.sh --bazel regenerates the JSON, scripts/bench_parse_maps.sh regenerates the timings, and the sabotage experiment is one sed away.