Engineering log

Orbit Goes Rust

A running account of converting a 34,000-line C++ profiling backend to Rust, one module at a time, with the C++ kept in place and both implementations checked against each other on every call.

Orbit is a native application profiler: sampling, dynamic instrumentation, scheduling and GPU tracing, with a Qt desktop UI and, more recently, a Rust/WebAssembly live viewer. The viewer moved to Rust first. These posts are about whether — and how — the capture backend should follow, written as it happens rather than afterwards.

The rules this log holds itself to:

Posts

25 · multi-machine

One Timeline, Many Machines

Post 22, built: a remote joins a hub over one WebSocket, the hub packs the machine into the top byte of every pid, remaps the strings and fits offset and rate through the fastest round trips so two machines' events share an axis; Record on the hub records everywhere. Diagrams of the link, the id, the clock fit and the ingest path; what was measured and what is deferred.

24 · cross-platform

The Windows Backend

Orbit returns to Windows in Rust: one ETW kernel session for the scheduler, thread states and 1 kHz sampling, decoded from raw MOF bytes rather than a schema parser; manual scopes in a file-backed ring; Frida hooks over a named pipe; symbols from PDBs and export tables. The C++ tracer's ContextSwitchManager tests are the oracle. Built and run on the OS, with the gaps stated.

23 · tooling

A Profiler an Agent Can Drive

orbit-service mcp lets an agent list processes, attach, arm Frida hooks, capture, and read hotspots, call stacks, and per-function timings. Twelve tools, stdio or loopback HTTP. The Python optimize-and-patch loop was not merged: it edits source, and this server does not.

22 · aggregation

Every Machine in One Timeline

A proposal for one viewer watching any number of machines at once — reached over the network, over SSH with nothing pre-installed, or by the remote dialing in. Most of it is plumbing; the real problem is putting two hosts' clocks on one axis, and the plan says what precision it will and will not promise. A design for review; nothing here is implemented yet.

21 · instrumentation

The First Few Bytes

One loop produces three scopes for one call; another accepted hook crashes. Follow the entry patch, relocated instructions and return detour through Orbit's native Frida integration, with a source-level comparison to hijk and the limits across Linux, macOS and Windows.

20 · instrumentation

One Lost Return, Ten Thousand Ghosts

A hooked function kept appearing nested inside itself, tens of thousands of times. Not a kernel that duplicates hits on migration, as the lore says, nor one that loses them in bulk, as a first bad reading of the data claimed: one return lost per thread at the instant of arming, turned into a landslide by pairing entries to returns by count. With two real captures embedded.

19 · testing

Call for Call

A stress test for dynamic instrumentation: three hooked functions in a known tree, called a known number of times on a known number of threads, and a checker that holds the capture to it — no extra scopes, no outer under anything, and every missing scope matched by a healing count on the status line. Written after the ghost scopes turned out to be lost hits, not duplicates.

18 · formats

Bytes on the Wire, Bytes on Disk

Why the live stream packs a 32-byte record into 11 with a delta and three varints, why deflate on top is a toggle and not the default, why ssh -C gets close in bytes and loses by an order of magnitude in CPU, and why the saved capture is Parquet with no codec at all — every choice with the number that made it.

17 · performance

Where the Frame Went

Measure first, fix what the measurement names, measure again: a 19× faster report tree, 49× faster frame symbolization, a 26× faster track layout, a 36× faster WebSocket drain — and a bug older than the port that had been rebuilding the timeline on every still frame.

16 · renderer internals

How Orbit Draws a Million Scopes

A line-by-line reading of the three WGSL shaders behind the live timeline, for someone who has never opened a shader: normalised device coordinates, 48 bytes per rectangle, a quad deliberately bigger than the thing it draws, and how you round a corner with no rounded rectangle.

15 · manual instrumentation

A Ring Per Core

A ten-nanosecond write path for manual instrumentation: why per-core single-producer rings are unsound in userspace, what rseq is and why it was not used, and the ordering bug that only appeared when someone asked whether a migrating thread still merges correctly.

14 · dynamic instrumentation

The Hook That Would Not Arm

Hooking functions from the web UI: the file-offset arithmetic a uprobe needs, why probes are opened per task instead of per CPU, and the capability check that stopped the last step. Reported with the gap left in.

13 · ease of build

Fourteen Times Faster

The softest of the four original goals, measured: the C++ service builds cold in 148.5 s, the Rust one in 10.7 s. Why the gap is that wide, what the number does not say, and why eleven seconds is a different activity from two and a half minutes.

12 · the viewer

Thirty-Two Workers

The WASM viewer spawned one web worker per hardware thread because that is what navigator.hardwareConcurrency returned, not because anyone chose it. The case for turning it down, the case against, a new default of eight, and a ?threads=N override so the right number can be measured instead of argued.

11 · phase 7, milestones a–b

Dead Simple

The pod wire format the project asked for at the start: a tag byte and fixed fields, zero dependencies, 4.79x faster to parse than protobuf at 1.69x the bytes — an honest CPU-for-bandwidth trade. Then the first all-Rust vertical slice, sched records to pod SchedulingSlice events with no FFI on the path.

10 · phase 6, milestones a–d

Into the Tracee

User-space instrumentation: the ptrace substrate (verified by a tracee confirming its own overwritten byte), syscall injection via the two-byte 0f 05, trampoline placement within 32-bit reach, and the hardest single file — instruction relocation, byte-for-byte identical with the C++ across 888,154 real instructions on iced-x86.

09 · phase 5, milestones a–c

The Weld Comes Apart

framehop replaces libunwindstack: 12,000 live samples unwound by both engines, zero frame divergences after the differential caught a shipping-grade off-by-one on its first run. Then the two welded classes fall — one weld was a single predicate, the other three questions — and their logic runs on Rust by default.

08 · phase 4, milestones a–c

Moving the Boundary

Instead of batching FFI calls, the boundary moves to the wire: Rust now owns the record layouts (19 structs, every field offset compared), the record parsing (7,230 live kernel records, zero mismatches), perf_event_open and the ring buffer (600/600 against a C++ watcher), and the event loop — the first FFI-free path from kernel bytes to ordered records.

07 · phase 3, reopened

Overruled

The project owner overruled the performance gate: port everything portable, accept the measured toll, fix it later. Five classes now run on Rust by default, the cost is priced at ~2% of a core worst case, and the two exits are named.

06 · phase 3

The Gate Says No

Two LinuxTracing classes ported, provably equivalent — and measurably slower, because a per-event FFI call cannot inline. Phase 3 stops on its own gate: defaults stay C++, five classes stay unported, and the verdict names what could reopen it.

05 · stage 2h

LLVM Is Gone

Post 04 called the PDB reader blocked. It wasn't — the CodeView renderer is two hundred lines, and once it existed line info fell too. Cold build −34%, ObjectUtils −2,191 lines, zero LLVM dependents, and an accounting of what deleting the comparison cost.

04 · PE/COFF, and the PDB blocker

Two Predicates, One Header

CoffFile fully ported, and a one-symbol mismatch that traced to picking the wrong "is this a function" predicate out of the same LLVM header. Then the PDB reader turned out to be blocked on LLVM's CodeView type-name renderer — so LLVM stays, and now we know exactly why.

03 · unwind ranges, line info

The Stage That Didn't Ship

16.3 million symbols compared with zero structural mismatches. Then line info passed every test in the repository and failed against real binaries four times — and the fourth one is why it still delegates to C++.

02 · ObjectUtils

944,291 Symbols

ELF parsing to the object crate: 20 tests green on the first run and up to 50× faster. Then the demangler failed twice, the mainstream Rust crate turned out to have a correctness bug, and the winner was not written in Rust.

01 · ParseMaps

The First 73 Lines

Porting the smallest real function in the tree to find out whether the machinery works. 1.75× faster, zero dependencies, and two bugs found — one of them in my own test. Eight hurdles, including why /proc paths cannot be &str.

Background reading

Analysis

Should OrbitService Be Rust?

The case both ways, module by module, including the parts that should stay C++ permanently and the reason the standard memory-safety pitch is weaker here than advertised.

Plan

Two Backends, One Test Suite

The staged plan: how one factory runs three backends, the four verification gates, and why deleting LLVM is a bounded outcome rather than an aspiration.

Prior work

Orbit on Bazel

The build-system port that made all of this possible, including the SSL problem that took three attempts.

The evidence trail

Two scripts produce everything the posts cite, and their output is committed so a reader can diff phase to phase rather than take a summary on trust.

CommandProducesRecords
scripts/port_metrics.sh --bazel docs/blog/metrics/*.json Lines of code per module, Bazel dependency and external-repository counts, test-case counts, uses of hand-rolled std equivalents, LLVM's dependent count
scripts/bench_parse_maps.sh docs/blog/metrics/*-bench-*.txt A/B timings: the same binary run under each backend, alternating rounds, median reported

The snapshots so far: phase-0-baseline.json taken before any Rust was written, and phase-1-parse-maps.json after the first port.