Why YJIT Moved Away From C
Implementing a JIT compiler in plain C became a growing burden for the YJIT team at Shopify. The team initially chose C99 for YJIT to keep integration with CRuby—itself a large C99 codebase—as simple as possible. But while many assume the biggest pain points in C are buffer overflows and null pointer dereferences, the daily friction came from the language's limited tools for managing complexity.
C offers no modules or namespaces, forcing prefix-based identifier conventions to avoid collisions. Developers must carefully manage declaration order and prototypes, with information duplicated across header files. The preprocessor introduces its own set of quirky bugs. There are no classes, interface types, or standard container types—the team built a custom dynamic array type manipulated through untyped preprocessor macros.
Compilers rank among the most complex software systems, and JIT compilers arguably add further difficulty in debugging and validation. YJIT began by compiling CRuby bytecode directly to x86 machine code, but plans called for a custom Intermediate Representation (IR) to decouple code generation from the frontend and enable future ARM64 support. Implementing that IR with C preprocessor macros, manual memory management, and no interface types seemed like too much—the team felt it was approaching the practical limits of C.
Senior developer Alan Wu proposed porting YJIT to Rust, drawn by its strong type safety. The idea quickly gained traction as the team recognized Rust's better tools for managing complexity. The CRuby core team approved the move in January, and the port began shortly after. Other options like Zig were briefly considered—YJIT's few dependencies would have made a port feasible—but Rust won out due to its maturity and active community.
The porting effort involved four of the six YJIT developers actively working on the Rust version. The project totals about 11,000 lines of C code, with a non-trivial interface to CRuby that requires parsing bytecode and manipulating every primitive Ruby type. Some internal CRuby APIs that YJIT depends on are not guaranteed stable over time.
Porting Strategy and Initial Impressions
The team chose to translate the existing C code into Rust more or less directly, commenting out the original and porting functions and structs one by one. This approach kept the general architecture close to the original, making the port faster and less error-prone—though it meant not yet taking full advantage of Rust idioms.
For a developer coming from C and C++, Rust felt surprisingly different: less like a C-family language and closer to ML-style languages. This makes sense historically—the first Rust compiler was written in OCaml. Though the learning curve is steep, most team members became comfortable after two to three weeks, aided by plentiful documentation from Rust's large community.
Rust's lack of a garbage collector is a major advantage for a JIT compiler. The borrow checker, often cited as difficult, was familiar territory for a team lead with a PhD in compiler design—though dynamic borrow checking with mutable RefCells still presented challenges.
Pattern Matching, Macros, and Build Tooling
Rust's ML-inspired pattern-matching syntax proved to be one of its best features—simple, powerful, and well-integrated with enums and structs. The macro system offers a significant improvement over C preprocessor macros in both safety and ergonomics, reusing pattern-matching syntax in an intuitive way.
The team used macros to add statistics and profiling counters to generated machine code, active only in dev (debug) mode. These counters track what causes YJIT to exit to the interpreter during benchmarks, guiding optimization priorities.
Cargo, Rust's build system, earned generally positive feedback. Its optional features for conditional compilation are better than a large set of C preprocessor ifdefs, and embedded tests are convenient. However, integrating with CRuby's build presented challenges.
CRuby is distributed as a tarball and built from source. Keeping self-contained distribution required building YJIT offline without external crates from crates.io—which proved difficult. The cargo build --offline switch complained about internet access even with no external crate dependencies. The cargo team suggested cargo vendor, but the team found that solution suboptimal. They eventually settled on using rustc directly for release-mode builds.
The libc crate, a key dependency, also requires crates.io access in a default cargo installation. The team worked around this by defining helper functions in the CRuby codebase and exposing them to Rust.
FFI Integration and Bindgen Limitations
YJIT's interface to CRuby spans on the order of 140 C functions, 30 structs and unions, and 500 constants across hundreds of source files. Bindgen, Rust's tool for auto-generating FFI bindings from C headers, was central to this effort.
Bindgen requires regex-style allowlist patterns for names to export. This system caused problems: some patterns silently failed to find definitions with no error message and no verbose mode to diagnose the cause. Whether the failure came from header parsing, missing definitions, or other settings remained opaque—the only option was to guess and adjust settings. The team filed a GitHub issue that had not received a response.
Bindgen likely works well for smaller projects or Rust-centric codebases interfacing with a stable public C API. For a project like YJIT, which needs many non-public definitions from a large legacy codebase, bindgen required supplementation with manually written C bindings for everything it silently omitted.
Integer Casting Pain Points
Compiler work means constant integer math, and YJIT needs the full spectrum of Rust's integer types: signed and unsigned, from 8 to 64 bits. Operations frequently mix types, and unlike C, Rust performs no implicit widening. Every mismatched operand requires an explicit cast, and array indexing forces the use of usize (C's size_t equivalent). The friction is real, and the Rust community has been discussing these ergonomics for years.
The trouble is that the manual casting requirement pushes developers toward inefficient code. Writing casts everywhere is verbose, so the path of least resistance is to use the widest integer type everywhere and minimize conversions. That makes the source look cleaner while bloating data structures. For a JIT compiler that allocates tens or hundreds of millions of objects, compactness directly impacts data cache performance. A u8 where a u64 is unnecessary is bytes saved across millions of allocations. Rust could encourage better performance by making safe promotions (like u8 to usize) implicit.
Cyclic Graphs and Ownership
YJIT, like any optimizing compiler, manipulates a Control Flow Graph (CFG) that is cyclic and mutated on the fly. This sits awkwardly with the borrow checker, which forbids cycles. Rust's standard toolkit for such cases — Rc and RefCell — provides interior mutability, but the documentation itself calls this approach "something of a last resort."
For a pure ahead-of-time compiler written entirely in Rust, reference counting might suffice. But YJIT generates machine code that retains references to CFG blocks, and the team ran into a subtle bug with reference-counted memory management. The future adds more complexity: eventually a machine-code garbage collector will interface with Ruby's GC. The pragmatic decision was to sidestep smart pointers and manage the CFG with Box, Rust's manual heap allocation type. Sometimes manual memory management is the only clean answer, and Rust at least provides the tools to do it.
String Handling: Better Than C, Still Fiddly
Rust's string manipulation is a clear step up from C. Memory is managed automatically, eliminating entire classes of buffer overflows, out-of-bounds accesses, and leaks. The standard library also offers far more string functions than C's.
The downside is the sheer number of string representations. YJIT deals with the owned String, the borrowed &str, and because it interfaces with a C codebase, the CString/&CStr pair plus raw c_char pointers. Operations require different type combinations, and conversions between them aren't always intuitive. Each string task can mean digging through documentation to find the right conversion formula. It's an improvement over C in safety, but not in ergonomics — a recurring theme in the porting experience.
The Unsafe Friction
Rust's strict aliasing, mutability, and type rules exist to prevent crashes and vulnerabilities while enabling optimizations. But those rules clamp down precisely where a JIT compiler lives: C interop and raw memory manipulation. Every C function call, every access to a C global, and every raw pointer operation must be wrapped in an unsafe block. When hundreds of code snippets need this treatment, the blocks become visual noise.
The information being conveyed is arguably redundant. If the compiler knows a call targets a C function — which by definition doesn't follow Rust's typing rules — why must the programmer annotate each call individually? It adds friction and feels like a constant reminder of the compiler's judgment.
This creates a strange inconsistency. On one hand, Rust enforces a strict type system and warns about style. On the other, it offers a wide assortment of escape hatches: unsafe blocks, unbounded integer as casts, and methods like into_raw/from_raw on types like Rc. Rust is safe as long as you don't do anything unsafe — but knowing exactly what violates the compiler's assumptions requires deep study. The metaphor that fits: C++ is a chainsaw, Rust is an electric nail gun with safety goggles and a 400-page safety booklet.
Where Rust Shines and Stumbles
Given the choice between Rust and C/C++ for a new project, YJIT's lead would pick Rust nine times out of ten. The port was motivated by a desire for better tools to manage code complexity, and Rust delivers on that promise. The challenges encountered were surmountable, with solutions or workarounds found in every case.
Some headaches are specific to porting rather than greenfield development. The impedance mismatch with C code is the big one — a fresh Rust project would minimize direct C interop and lean on idiomatic Rust from the start, avoiding the awkward translation layer.
The ecosystem also needs time to mature. The team hit issues with cargo and bindgen, and useful APIs like SyncLazy remain nightly-only. Standardization of such features would close the gap between Rust's potential and its practical ergonomics.
The port itself took three months. The result is, in the author's assessment, significantly more maintainable than the original C codebase and has brought fresh energy to the project. The next steps involve refactoring the code to be more idiomatically Rusty rather than a direct C translation, better leveraging the language's abstraction and organization strengths.



