Runtime IR and optimization
Runtime-module curation, module linking, LLVM verification, optimization levels, alias metadata, object emission, and ORC JIT execution.
Last verified
Prismio emits program IR and combines it with runtime support before producing a native artifact. The backend also runs LLVM's standard optimization pipeline and offers an opt-in ORC JIT path. These are separate mechanisms: changing execution mode must not change the module generated from the source program.
Verification and optimization order
ir_set_opt_level clamps the requested level to 0–3. ir_write_file then:
- calls
LLVMVerifyModuleon the unoptimized module; - runs
run_optimizationwhen the level is greater than zero; - verifies the optimized module again; and
- writes textual IR using
LLVMPrintModuleToFile.
Verifying before optimization preserves the useful failure boundary. Passing invalid IR into the pass pipeline can produce an opaque crash or a secondary error far from the builder call that created it. Verifying afterward catches any invalid metadata or transformation assumptions exposed by the pipeline.
run_optimization constructs default<O1>, default<O2>, or default<O3>, creates
LLVMPassBuilderOptionsRef, and calls LLVMRunPasses. It passes no target machine, so this is
LLVM's target-independent module pipeline; the later native tool still performs target-specific
instruction selection and machine optimization.
O1 already matters because Prismio deliberately emits addressable slots for source bindings.
Mem2reg and SROA remove ordinary stack traffic. Higher levels add inlining, loop transforms,
vectorization, global simplification, and more aggressive code-size/runtime tradeoffs.
Metadata supplied to the optimizer
Optimization is only sound when the backend exposes facts it has proved:
tag_scalarattaches scalar TBAA to ordinary loads and stores.struct_field_tbaa_tagcreates struct-path tags using target offsets.tag_list_headerseparates header fields such as length, capacity, element size, and data.tag_list_elementidentifies element storage without claiming it cannot alias another element.tag_list_regionscopes a proven non-overlapping element region.tag_data_viewdistinguishes view fields and backing storage.tag_invariant_loadmarks a load invariant only when mutation cannot invalidate it.tag_list_count_rangeattaches a valid integer range after the runtime invariant is known.
The backend also tags known runtime declarations. List constructors can receive return noalias
and function memory/nounwind/willreturn attributes. Mutator declarations receive conservative
memory behavior. These are not performance hints: an incorrect alias or memory attribute gives
LLVM permission to change observable behavior.
The guarded list operations in llvm-api-backend.c are designed to expose fast paths:
ir_list_flat_scalar_elem, ir_list_flat_scalar_set, ir_list_flat_push_scalar,
ir_list_flat_copy, and ir_list_flat_zero_append create checked straight-line access when
the frontend has emitted the necessary representation, capacity, and range guards. The fallback
calls the ordinary runtime helper.
Library module merging
Runtime and standard-library support arrives as LLVM bitcode, and ir_link_library_modules merges
every selected module into the program in one context, one transaction. Linking them one at a
time reparsed and reprinted the growing program per input, which made a module-wise package
accidentally quadratic in serialization work — a large program crossed the text-IR boundary sixteen
times before optimization began.
Each source module is prepared before it is linked. preserve_program_declaration_contracts keeps
the program's own declaration attributes from being overwritten by the library's;
clear_packaging_target_attributes strips target-cpu, target-features and tune-cpu, which are
packaging-time tuning rather than a portable bitcode contract;
mark_runtime_structural_invariants reattaches the facts a portable runtime build cannot express,
such as the immutability of a list's inline stride.
mark_library_interface_functions then applies one policy to both PLIB and runtime boundaries.
Functions cheap enough by a cost model that scores a call far above arithmetic receive
inlinehint — an eligibility filter, not a decision, since LLVM's target-aware model still
chooses. Functions that read the environment or take a thread-local address receive noinline
instead: their calls stay dynamic after inlining while the expanded control flow perturbs the
greedy inliner's later ordering. Both tests are properties of the IR, deliberately not a list of
blessed function names.
After the merge, prune_unused_imported_definitions deletes imported definitions with no remaining
IR users, repeating until fixpoint because removing one wrapper can make its callees dead.
Reachable definitions keep external linkage, so this feeds no stronger visibility promise to the
inliner.
The older curated-extraction path — which compiled one runtime translation unit, cut a named subset
out of it with ir_curate_module, and merged only that — has been superseded by merging the shipped
bitcode whole. PRISMIO_CURATED_OPS in build_driver.c survives as a maintained list that the
curated_emits and curated_closure fixtures check against codegen, and its merge path is no
longer reached by an ordinary build.
ir_link_modules(dest_ir, src_ir, out_path):
- creates a fresh context;
- reads both files with
LLVMCreateMemoryBufferWithContentsOfFile; - parses them with
LLVMParseIRInContext; - calls
LLVMLinkModules2, which consumes the source module; - writes the combined module; and
- disposes only the objects still owned by the caller.
LLVM object ownership matters here. Disposing the source module after a successful
LLVMLinkModules2 is a double-free; omitting disposal of the destination/context on an early
parse error leaks compiler-process memory.
Object and native output
Target selection creates LLVMTargetMachineRef from the chosen triple. The object path
(ir_emit_object in runtime/llvm-api-backend.c) sets the module triple/layout, runs verification
and optimization, and emits target objects with LLVMTargetMachineEmitToFile. Runtime and
standard-library bitcode has already been merged into that module, so the platform linker is
invoked with the program's objects plus UMS native link inputs.
Internalization
A closed executable -- no native {} sources, no linked objects or response files, no
exportDynamic (program_is_closed in build_driver.c) -- has main as its only root, so
every other definition is made internal before the pipeline runs (internalize_executable), and
the pipeline starts with globaldce. This is the internalize step of an LTO link. Unused standard
library functions and the tables they reach are never optimized, code-generated or linked; the
benchmark suite went from 559 KB to 241 KB, and its optimization stage from 1.89 s to 1.06 s.
Internal linkage changes inlining. LLVM inlines an internal function's only call whatever its
size, which gave real wins (a string search specialised on its constant needle ran 0.55x) and
one systematic cost: a slow path split out on purpose so its fast half can inline into a caller's
loop is folded straight back. Size used to keep such halves apart. They must now say so --
cold fn in Prismio (cold functions)
and PRISMIO_NOINLINE in the runtime. A new fast/slow split needs the same marker, or its fast
half will quietly stop inlining.
The opposite marker is inline fn (inline functions), alwaysinline, for a function whose body a hot loop needs in view but whose size a cost model counts against it; std's mapProbe is the one user.
Parallel machine code
After the whole program is simplified as one module, it is split into up to eight partitions
(heaviest function first onto the lightest partition, about 5,000 instructions each at least) and
each is optimised and lowered on its own thread, in its own LLVMContext, from one shared bitcode
image. default<O3> runs in two halves (prismio_llvm_run_default_half in
runtime/llvm_bridge.cpp, built exactly as LLVM's buildPerModuleDefaultPipeline): the module
simplification pipeline -- the CGSCC inliner and the function simplification it drives -- on the
whole program, then the module optimisation pipeline -- loop rotation, vectorisation, unrolling and
their cleanups, one function at a time -- in each partition. Every inlining and IPO decision has
been made on the whole program, so unlike rustc's codegen units nothing that crosses functions is
given up for the parallelism. On the compiler's own build the pipeline and machine code take
3.63 s, against 4.30 s with the second half on one thread.
The split is not byte-identical to one default<O3>. Analyses the first half leaves cached --
InstCombine's last-run tracking among them -- cannot cross into another thread's context, so the
second half recomputes them and InstCombine runs where it would have been skipped. It was measured
rather than assumed: 47 hosted workloads at ten times their size, geomean 0.995 against 1.000 for
an identical-binary control, every result equal. A program too small to partition (counted after
globaldce, before optimisation) keeps the single pipeline and its exact output, and
PRISMIO_SPLIT_PIPELINE=0 turns the split off. Windows has no bridge yet and runs it whole. A local referenced across a partition boundary becomes a hidden global of the same
name; constants are copied into each partition that reads them. Machine code for the compiler
itself went from 2.1 s to 0.66 s. A module with aliases, ifuncs or inline assembly is emitted
whole.
Each partition loads the shared bitcode lazily and materialises only the bodies it owns; the
rest become declarations unread (prismio_llvm_drop_body in runtime/llvm_bridge.cpp). Every
partition used to parse the whole module and delete seven-eighths of it, ~170 ms per partition on
the compiler's own build before any code was emitted. With lazy loading, machine code for the
compiler takes 608 ms (was 754). Windows has no bridge yet and parses whole.
A -g or -O0 build is partitioned too. Each partition keeps the compile unit and the
subprograms of what it emits, so the program has one compile unit per partition, all named for
the source, as an LTO-partitioned link does; dwarfdump --verify is clean and lldb resolves
breakpoints, locals and globals exactly as in a one-partition build. The compiler's -g build
spends 216 ms in machine code (was 512 on one thread).
delete_function_body turns a body another partition owns into a declaration. It must erase
every instruction before deleting any block: a branch in a later block still names an earlier
block, and release LLVM does not assert on deleting a block that is still used. With one thread
the freed memory was rarely reused in time; with eight it corrupted other partitions' IR.
Switches
| Variable | Effect |
|---|---|
PRISMIO_BUILD_TRACE=1 | Stage timings, including parse merged IR, IR pipeline, machine code and the partition count |
PRISMIO_CODEGEN_THREADS=n | Exactly n partitions regardless of size; 1 emits the module whole |
PRISMIO_CODEGEN_VERIFY=1 | Verify each partition's module before lowering it |
PRISMIO_SPLIT_PIPELINE=0 | Run default<O3> whole on one thread instead of splitting its second half into the partitions |
PRISMIO_SAVE_IR=<file> | Write the merged module the pipeline is about to run on, for timing opt/llc offline |
On Mach-O the link passes -dead_strip, so unreferenced functions in native objects go too;
exported symbols (exportDynamic) are roots and survive.
The merged module stays in memory
merge_libraries_into_program still names a libraries-<pid>.ll, but a build no longer writes it:
ir_hold_merged_module keeps the linked module and its context, and ir_emit_object adopts them
when asked for that path. Printing and re-parsing it cost 70 ms of the benchmark suite's build
(16 MB of text for the compiler). run --jit and PRISMIO_CODEGEN=clang still get the file.
What exportDynamic exports
On macOS and Linux it is the target's native objects' defined symbols, read with the toolchain's
llvm-nm and passed as -exported_symbols_list or --dynamic-list (unix_export_flags) -- what
Windows has always done. It used to be -rdynamic, which for the compiler exported ~44,000 LLVM
C++ symbols and made every one a -dead_strip root: the binary went from 135.8 MB to 125.2 MB. If
nm cannot run, the link falls back to -rdynamic.
An .ll output intentionally stops before native object/link stages. It is the best debugging
boundary for checking type shapes, call attributes, ownership helpers, vtables, blocks, and
optimizer effects.
ORC JIT path
ir_jit_run_main is used only for explicit JIT execution. It initializes the native target and
assembly printer, creates an LLJIT instance, and makes host-process symbols visible. The existing
module cannot be handed directly to LLJIT because it belongs to the backend's context.
The function therefore:
- serializes
g_modulewithLLVMWriteBitcodeToMemoryBuffer; - creates a new
LLVMContextRef; - parses the bitcode into that context;
- transfers the context to an ORC thread-safe context;
- transfers the parsed module to a thread-safe module;
- adds it to the JIT dylib;
- looks up the generated
main; and - calls it with the program-support argument globals already initialized.
Every ORC operation returns LLVMErrorRef. jit_failed and jit_failed_unresolved convert
those objects to messages and dispose them. Ownership transfer is explicit: after an ORC
constructor consumes a context/module, the original cleanup path must not dispose it.
Evaluating an optimization change
Start from equal source and checksums. Compare unoptimized IR, optimized IR, and final assembly. Record function mnemonic counts so metadata-only movement does not look like code growth. Use an A/A timing floor, multiple samples, medians, and the checked-in benchmark harness. Finally run fixed-point generation: an optimization that speeds a small program but destabilizes or miscompiles the self-hosted compiler is not acceptable.