Skip to content

★ Lab 24.2 · pebble-jit: an ORC session and a REPL for Pebble

Chapter: 24 · The Complete Pebble Compiler — and Beyond · Lesson: 24.3 (also 10.8, 11.9) · Time: 4–6 hours · Tests: ./course test 24 (labels ch24 and star)

1. Goal

Run Pebble in process. pebble-jit program.pbl compiles the program with the same front end, code generator and pipeline as pebblec and executes pebble_main through ORC instead of writing an object file; pebble-jit --repl keeps a session open and evaluates lines as they come. You write the session: an eager configuration on LLJIT (Algorithm 24.3.3) and a lazy one on LLLazyJIT (Algorithm 24.3.5), behind one interface, with a count of compiled function bodies that makes the difference measurable (Proposition 24.3.8: called iff compiled).

2. The contract and the command line

include/pebblejit/JIT.h; your code in src/ (any files; Stub.cpp stops with TODO(ch24)):

namespace pebblejit {
struct Options { bool Lazy = false; bool Trace = false; };
class Session {
public:
  static std::expected<std::unique_ptr<Session>, pebble::Error> create(const Options &);
  virtual std::expected<int64_t, pebble::Error>
  run(std::unique_ptr<llvm::Module> M, std::unique_ptr<llvm::LLVMContext> Ctx, std::string_view Entry) = 0;
  virtual unsigned compiledFunctions() const = 0;
};
}

The provided driver (tools/pebble-jit.cpp):

pebble-jit [--lazy] [-O0|-O1|-O2] [--jit-trace] [--jit-stats] program.pbl
pebble-jit [--lazy] [-O0|-O1|-O2] [--jit-trace] --repl

It compiles in process (front end → PIR → codegen::lowerPIRToLLVM → driver::optimizeModule at the level) and calls run(M, Ctx, codegen::EntryPointSymbol). The exit status is the program's result modulo 256, or 1 on a compile error; a trap ends the process with 101 as for native code. --jit-stats prints pebble-jit: compiled N function(s) on stderr at exit. In --repl, a line starting with fn, struct or extern begins a declaration that ends when its braces balance, and declarations that compile are kept; any other line is evaluated in a fresh main: a line ending in ; is a statement, anything else an expression whose value is printed; a line that fails to compile is reported and skipped. Prompts go to stderr.

3. Requirements

  • R1 (runtime symbols). Every function of pebble/runtime that generated code may call (pebble_print_int, pebble_print_float, pebble_print_bool, pebble_print_str, pebble_print_newline, pebble_trap, ..., and the C library's fmod, which float % lowers to) resolves in every module. Publish them with absoluteSymbols into the main JITDylib: the runtime is statically linked into the driver and not exported (-rdynamic is not portable to every platform this course supports), so DynamicLibrarySearchGenerator::GetForCurrentProcess does not find pebble_* (fmod lives in the shared C library and would be found, but publishing it too keeps the session independent of how the driver was linked).
  • R2 (many modules). Every run creates a fresh JITDylib (pebble.N) whose link order includes the main dylib, adds the module to it and looks up Entry there. Modules that all define pebble_main (the REPL's <repl:N> modules, ManyModulesInOneSession) coexist. The module's own functions may be internal.
  • R3 (eager vs lazy). With Lazy == false, run adds the module with addIRModule and the lookup of Entry compiles the whole module: compiledFunctions() counts every defined function. With Lazy == true (LLLazyJIT, addLazyIRModule), only the functions that are actually called are compiled: on the test program (used, unused, also_unused, main) eager gives 4 and lazy gives 2.
  • R4 (counting and tracing). Count function bodies in a transform installed with IRTransformLayer::setTransform on the JIT's transform layer (with the lazy JIT each materialization unit holds one function, so the count is exact); Trace prints pebble-jit: compiled <function> per body in compilation order.
  • R5 (errors and traps). A missing Entry is a returned pebble::Error, not a crash (MissingEntryIsAnError); a trap in JITed code calls the runtime's pebble_trap, which prints pebble: trap: <kind> at <file>:<line>:<col> and exits 101 exactly as native code (Inputs/trap.pbl); the module's LLVMContext is kept alive by the session (or by ORC's ThreadSafeContext) as long as the module may still be compiled lazily.

4. What the tests check

Test What
ch24.JIT.EagerRunsAndCompilesEverything result 42, compiledFunctions() == 4 (R3)
ch24.JIT.LazyCompilesOnlyWhatIsCalled result 42, compiledFunctions() == 2 (R3, R4)
ch24.JIT.ManyModulesInOneSession three modules defining pebble_main, results 10, 20, 30 (R2)
ch24.JIT.MissingEntryIsAnError run(..., "no_such_function") returns an error (R5)
tests/ch24/lit/jit.pbl output 42 and 3.5 at -O0, -O1, -O2; --jit-stats reports 4 eager and 2 lazy; --jit-trace names pebble_main; trap.pbl traps with the native message and column (R1, R3–R5)
tests/ch24/lit/repl.test the session Inputs/session.txt: a declaration, an expression (144), a statement, a float, an erroneous line (skipped), a recursive fn, a struct; eager and lazy (R2)

5. Milestones

  1. Eager. LLJITBuilder().create(), absoluteSymbols for the runtime, addIRModule into a fresh dylib per run, lookup(Entry), call it as int64_t (*)(). jit.pbl's first RUN line and three unit tests pass.
  2. Counting. The transform layer; --jit-stats prints 4.
  3. Lazy. LLLazyJITBuilder, addLazyIRModule; the count drops to 2; --jit-trace shows pebble_main first, then used.
  4. REPL. Nothing to write: the driver's REPL only needs R2. Run Inputs/session.txt and then try your own: fn declarations, expressions, statements.

6. Hints

  1. The LLJIT owns a ExecutionSession; getMainJITDylib() is where the runtime symbols go; createJITDylib("pebble." + std::to_string(N)) per run, then JD.addToLinkOrder(Main).
  2. absoluteSymbols({{ES.intern("pebble_print_int"), {ExecutorAddr::fromPtr(&pebble_print_int), JITSymbolFlags::Exported | JITSymbolFlags::Callable}}, ...}); take fmod's address as static_cast<double (*)(double, double)>(&std::fmod) to pick the overload.
  3. J.getIRTransformLayer().setTransform([&](ThreadSafeModule TSM, MaterializationResponsibility &) { TSM.withModuleDo([&](Module &M) { for (auto &F : M) if (!F.isDeclaration()) ++Count; }); return std::move(TSM); }). In the lazy JIT this runs per extracted function.
  4. LLLazyJIT needs a LazyCallThroughManager, which LLLazyJITBuilder sets up for the host; on a platform it does not support, create() returns an error, which the tests report. Both the eager and the lazy configuration must run Entry through lookup, not through a raw pointer taken before materialization.
  5. The result of a lookup is an ExecutorSymbolDef; toPtr<int64_t (*)()>() gives the function pointer.
  6. Keep the LLVMContext in a ThreadSafeContext that the module's ThreadSafeModule shares; the session, not run, owns the modules' lifetimes.

7. Stretch goals

  • A third tier: wrap the compile layer in a ReOptimizeLayer (Lesson 24.3, Algorithm 24.3.6) that recompiles a function at -O2 after 10 calls (ReOptimizeLayer::CallCountThreshold), and measure bench-fib at -O0, with re-optimization, and at -O2.
  • Remove a run's dylib when the REPL redefines a function (ES.removeJITDylib), so old definitions do not accumulate.
  • Speculative compilation: with --lazy, start compiling every function of a module on a background thread after pebble_main returns for the first time (ORC's Speculator).