Orbit C++ → Rust · Post 02

944,291 Symbols

Porting ELF parsing to the object crate went better than expected. Porting the demangler failed twice, and the way it failed is the most useful thing in this post.

4,719Real binaries compared
944,291Symbols compared
0Structural mismatches
50×Faster on a 4 MB file
12 / 5Ported / delegating
3Demanglers tried

ObjectUtils is the module the whole port is aimed at, because //bazel/deps:llvm has exactly one dependent and this is it. Two stages landed: the ELF metadata that ElfFileImpl::Initialize computes, and the two symbol-table loaders. Twelve of the seventeen ElfFile methods now come from Rust; five still forward to a C++ instance the Rust one owns.

A prerequisite nobody would have guessed

Before any Rust could go behind ElfFile, the interface had to stop naming LLVM. include/ObjectUtils/ElfFile.h included llvm/Object/ObjectFile.h, and CreateElfFile had an overload taking an llvm::object::OwningBinary. An interface that names llvm types cannot have a non-LLVM implementation behind it, and every one of ObjectUtils's sixteen dependents was seeing those headers.

This turned out to be free. grep for callers of the LLVM-typed overload found none outside src/ObjectUtils — every external caller uses the path-only form. So both overloads moved to an internal ObjectFileLlvm.h and the public headers lost their LLVM includes. Files in the tree that include an llvm/ header went from 11 to 9, with no behaviour change and no test touched.

Worth doing anyway

This is a cleanup that stands on its own merits and had been sitting there for years. It is the kind of thing a port surfaces not because Rust is better but because the exercise forces you to ask what an interface actually promises.

The easy half: metadata

Sections, dynamic entries and program headers, through the object crate. Twenty Rust tests, written against the same testdata files the C++ suite uses, asserting the same values ElfFileTest asserts — load bias, executable segment offset, image size, all four object segments of both a dynamic and a static binary, the build id, the soname, and that the hand-written CRC-32 matches llvm::crc32 on hello_world_elf.debug.

All twenty passed on the first run. That is not a claim about my code; it is a claim about the object crate, which reproduces llvm::object's answers on Orbit's own testdata without adjustment. Its entire transitive dependency tree is one crate, memchr.

The speed was the surprise. I expected parity and was ready to report a regression:

FileSizellvm::objectobject
hello_world_elf16 KB10.5 µs0.8 µs13.3×
hello_world_static_elf765 KB11.4 µs0.6 µs19.9×
hello_world_elf_with_debug_info17 KB12.7 µs0.5 µs24.6×
libtest-1.0.so16 KB13.3 µs2.1 µs6.5×
libc.debug4.2 MB33.6 µs0.7 µs50.0×

The speedup grows with file size, which tells you the mechanism: llvm::object::createObjectFile does its work up front, and the object crate is lazy. On a 4 MB file LLVM pays for structure Orbit never asks about.

What that number does not mean

It does not mean ORBIT_OBJECT_BACKEND=rust is faster today. While methods still delegate, the Rust ElfFile constructs a C++ one too, so the rust backend is necessarily slower end to end. The table times the two parsers directly, which is the only honest comparison until the delegate is gone. That is what the delegation counter in the metrics is for.

The hard half: three demanglers

Symbol tables looked like more of the same — read .symtab or .dynsym, keep defined STT_FUNC entries, record address, size and name. The reading was straightforward. The naming took three attempts, and ORBIT_OBJECT_BACKEND=both found each failure within seconds of the code compiling.

_ZNSt10filesystem7__cxx114pathC2IPcS1_EERKT_NS1_6formatE a constructor template whose first parameter is a reference to a template parameter llvm::demangle the reference we must match path::path<char*, path>( char* const&, path::format) correct · but is LLVM cpp_demangle 0.4 and 0.5 the obvious crate path::path<char*, path>( <parameter dropped> path::format) a wrong answer __cxa_demangle libstdc++, already linked path::path<char*, path>( char* const&, path::format) correct · no LLVM The winner is not a Rust crate. llvm::Demangle is one of the six LLVM libraries; abi::__cxa_demangle is libstdc++, which every C++ binary already links. Using it removes the dependency without needing a Rust demangler at all.
The obvious choice was the wrong one, and the right one was not written in Rust. cpp_demangle — the crate symbolic and most of the Rust ecosystem use — drops the char* const& parameter. That is not a formatting difference; it is a wrong answer, in both 0.4 and 0.5. Two Rust dependencies were added and then removed again.

Then the corpus said parity was impossible

With __cxa_demangle plus a small spacing normaliser — it emits the pre-C++11 char_traits<char> > and operator<< < where LLVM emits the modern forms — all 68 ObjectUtilsTests cases passed in all three modes.

Then I ran the same comparison over 4,719 real binaries, and it aborted on the second file. Not on structure: on rendering.

mangled  _ZSt14__copy_move_a1ILb1EPN4absl7debian912crc_internal12CrcCordState9PrefixCrcE...
llvm     _ZSt14__copy_move_a1ILb1EPN4absl7debian912crc_internal12CrcCordState9PrefixCrcE...
libstdc++ __gnu_cxx::__enable_if<std::__is_random_access_iter<absl::debian9::...>>::__type
          std::__copy_move_a1<true, ...>(...)

Here LLVM is the one that gave up and returned the symbol unchanged. Elsewhere they disagree on lambda notation — LLVM writes 'lambda'(), libstdc++ writes {lambda()#1} — and on whether to print a return type. None of that is reachable by normalisation, and demanding bug-compatibility would mean reproducing LLVM's limitations on purpose.

So the contract got split

This is the judgement call in the whole stage, and it is worth stating plainly rather than burying:

Over the corpus:

elf files seen            4719
loaded                    4709
symbols compared        944291
structural disagreements     0
rendering differences     2197  (0.2327%)

Zero structural disagreements across nearly a million symbols in /usr/lib/x86_64-linux-gnu and /usr/bin. The curated testdata is 34 files; it would never have found the demangler problems, and it would never have given that number either.

Hurdles

09 · my bug

The five-byte padding

After the demangling matched, both mode failed on is_hotpatchable for __cxx_global_var_init. I had read LoadHotpatchableAddresses, which parses __patchable_function_entries, and implemented it faithfully — then missed that the caller does not look up the symbol's address at all. IsHotpatchable looks up address - 5, because the recorded addresses point at the first byte of the padding rather than the function entry.

Two lines of C++, in a different function from the one I was porting. Nothing but a value-level comparison would have caught it: the types are identical and both answers are plausible booleans.

10 · my bug

Off by one in the normaliser

"operator<< <" should become "operator<<<". I erased index 11 instead of 10, deleting the < rather than the space, and produced operator<< char. The comparison named the exact symbol and printed both strings, so it took one test run to see.

11 · ecosystem

The mainstream crate has a correctness bug

cpp_demangle is what symbolic-demangle wraps and what most of the Rust ecosystem reaches for. On a constructor template whose parameter is a reference to a template parameter it silently drops the parameter, in both 0.4.5 and 0.5.1.

The useful part is not that a crate has a bug; it is how it was found. A dozen curated test binaries did not contain such a symbol. The C++ standard library, linked into a test file that had been in the tree for years, did.

12 · design

"Port it to Rust" was the wrong answer for one component

The demangler moved back to C++, and that is the right outcome. The goal is not maximal Rust; it is removing LLVM. abi::__cxa_demangle costs nothing — libstdc++ is already linked — and it is more correct than the Rust alternative. Two Rust dependencies were added and then removed.

A port that cannot reach this conclusion for any component is not evaluating, it is converting.

13 · build

The header split, again

Same cycle as ModuleUtils in post 01, at larger scale: the Rust shim implements ElfFile and therefore needs ObjectUtils's headers, while ObjectUtils needs the shim to dispatch to it. A headers-only ObjectUtilsHeaders target breaks it. Predicted in the plan, which is mildly satisfying.

The evidence, including what got worse

MeasureBaselineNowReading
Structural symbol agreement—944,291 / 944,291Across 4,709 real binaries
ELF metadata parse, 4 MB file33.6 µs0.7 µsParsers compared directly, not end to end
Files including an llvm/ header119The two public ObjectUtils headers
ElfFile methods on Rust012 of 17Five still delegate: unwind ranges and the three line-info methods
New external Rust crates—2object and memchr. cpp_demangle and rustc-demangle were added and removed
deps(//src/ObjectUtils)5,8438,362Worse by 2,519. rules_rust and crate_universe are in the graph now
ObjectUtils implementation lines3,6803,886Worse by 206. Both implementations, by design

The dependency count is worse than it was after post 01 and will stay worse until stage 2h. Nothing about that is surprising — the plan says the payoff is deleting //bazel/deps:llvm, and it still has one dependent — but a log that only reported the good half would not be worth reading.

Next

Stage 2c is the unwind ranges: walking .eh_frame and .debug_frame FDEs with gimli to synthesise symbols for functions that have none. Then 2d, DWARF line info, which is the last thing standing between here and deleting llvm::DebugInfoDWARF.

The delegation counter is at five. It has never gone up.