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 sets the
module triple/layout, runs verification and optimization, and emits a target object with
LLVMTargetMachineEmitToFile. Runtime and standard-library bitcode has already been merged into
that module, so the platform linker is invoked with the single program object plus UMS native link
inputs.
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.