Build & single binary¶
rbgo build turns a Ruby program into one static, self-contained
executable — no Ruby installation, no C toolchain, no shared libraries. The
trick is to compile everything reachable to bytecode, embed it, and let the Go
linker drop everything else.
How rbgo build works¶
- Scan the require graph. Starting from the entry script,
rbgofollowsrequires to discover exactly which standard-library files the program can reach. - Select only the reached stdlib. Each stdlib component is guarded by Go
build tags and embedded with
go:embed, so only the parts the program actually uses are compiled in — and the Go linker drops the rest as dead code. - Compile to bytecode. The application and the selected stdlib are compiled to ISeqs ahead of time.
- Embed and link. The bytecode is embedded into a small Go program that
bundles the VM, and a plain
go buildproduces a single static binary.
Closed-world mode¶
A program that never calls eval and never does a dynamic require does not
need the front-end at runtime. Closed-world mode takes advantage of this: it
drops the embedded lexer/parser/compiler from the binary, since all code was
already compiled to bytecode at build time. The result is a noticeably smaller
binary, at the cost of giving up runtime code loading.
The open-world default keeps the front-end embedded so eval and dynamic
require keep working (see Front-end).
Ahead-of-time method compilation (landed)¶
rbgo build already does more than embed bytecode: it compiles the program's
lowerable methods to native Go and links them in, so dispatch runs machine
code instead of the bytecode loop. Each method's bytecode is lowered to a Go
function (straight-line control flow, locals as Go variables, a direct call for
self-recursion); a method using something the compiler cannot lower yet simply
stays interpreted.
A method that is pure integer arithmetic on integer parameters (with
self-recursion) is specialised further, to an unboxed int64 kernel: the
boxed entry guards the arguments are integers and runs the kernel, and a
deopt edge recovers any signed overflow or divide-by-zero by re-running the
sound interpreted body — so the fast path stays correct for every input
(overflow still promotes to the identical Bignum). Because the whole program is
visible at build time and the Go compiler (plus PGO) does the backend, the
generated fib(30) runs ~4× faster than MRI + YJIT — an ahead-of-time
compiler with an unlimited budget beats a runtime JIT on compute-bound code, and
falls back to the interpreter for the genuinely dynamic parts. Design notes:
docs/aot-compiler.md.
Still ahead (the single-binary half of rbgo build): the require-graph scan,
build-tag/go:embed stdlib selection and closed-world mode described above.
Precedent¶
This is not a new idea: mruby does essentially the same thing with
mrbgems, where the set of compiled-in components is selected at build time via
build_config.rb. go-embedded-ruby's require-graph scan plus build-tag/go:embed
selection is the same closed-world philosophy expressed through the Go
toolchain.
The build toolchain is developed in full in Phase 7.