architecture Advanced

LLVM IR Generation

How pyvorin-native 1.0.9 lowers a typed Python function to LLVM IR through llvmlite — with a real before-and-after example taken from the compiler's own output.

Published Jan 26, 2026

Every function Pyvorin compiles passes through a text representation of LLVM intermediate representation before it becomes machine code. In pyvorin-native 1.0.9 the compiler builds that IR with llvmlite, verifies it with LLVM's own module verifier, optimises it with the standard pass pipeline, and only then asks LLVM to emit an object file. This page shows what the IR looks like, using output taken from the compiler itself rather than a hand-drawn diagram.

The example below is real: we compiled a small function and read the module the compiler generated. Understanding this stage matters because it explains two things that benchmarking turns up again and again — why pure numeric loops get large speedups, and why object-heavy code does not.

Why LLVM

Pyvorin is a Python-to-native compiler, and writing a native backend from scratch means building instruction selection, register allocation, instruction scheduling and target-specific code emission for every architecture you intend to support. LLVM already does all of that, with two decades of optimisation work behind it and a maintained x86-64 backend. Using it is not a philosophical choice; it is the difference between shipping a backend and shipping a science project.

The dependency is explicit in the package metadata: pyvorin-native requires llvmlite>=0.41. llvmlite is a set of Python bindings to the LLVM C API, maintained by the Numba project. Pyvorin uses it in two roles: the ir module, whose type system and builder construct the IR, and the binding module, which talks to LLVM proper — parsing, verification, pass management and object emission. Crucially, LLVM runs in the same process as your Python interpreter. There is no compile server and no subprocess for optimisation; the entire pipeline from IR text to object bytes happens in memory.

From Python to IR: the generator's view

The code generator walks the typed representation of a Python function and emits an LLVM module with the ID nexus_native. Three conventions shape everything that appears in the IR, and they are worth learning because they make the output readable.

Everything scalar is an integer or a double. Python objects do not appear as typed pointers in hot paths. Containers are passed around as 64-bit integer handles — a list parameter arrives as i64, and operations on it become calls such as nexus_list_len and nexus_list_get_data_ptr. Native class instances are malloc'd structs whose first field is a vtable pointer, again referenced by handle.

Variables live in allocas. Each Python local gets an alloca slot, and the generated code loads and stores through it rather than keeping everything in SSA registers directly. This looks naive to an LLVM reader, and it is — deliberately so. The alloca form is simple to generate correctly for arbitrary control flow, and LLVM's mem2reg pass promotes these slots to registers during optimisation. The generator trades elegance for correctness and lets the optimiser recover the elegance.

Control flow becomes labelled blocks. A for loop over a list lowers to a counted loop: a length call up front, an induction variable, a compare-and-branch header block, a body block, an increment block and an exit block. Exceptions and breaks add further blocks. The shape is mechanical, which is exactly what you want from a lowering.

A real before-and-after

Here is the function we compiled, and the IR the compiler produced for it. The IR is abridged — the full module carries several hundred lines of runtime declarations — but every instruction shown is from the actual generated output, with comments added.

def total(prices):
    s = 0.0
    for p in prices:
        s += p * 1.2
    return s
define double @"total"(i64 %".1")
{
entry:
  %"prices" = alloca i64
  store i64 %".1", i64* %"prices"          ; prices is a 64-bit handle
  %"s" = alloca double
  store double 0x0, double* %"s"
  %".10" = call i64 @"nexus_list_len"(i64 %".1")
  %"__for_idx" = alloca i64
  store i64 0, i64* %"__for_idx"
  br label %"for_list_header"

for_list_header:                            ; while idx < len(prices)
  %".16" = load i64, i64* %"__for_idx"
  %".17" = icmp slt i64 %".16", %".10"
  br i1 %".17", label %"for_list_body", label %"for_list_exit"

for_list_body:
  %".19" = call double @"nexus_list_fast_get_double"(i64 %".7", i64 %".16")
  store double %".19", double* %"p"
  %".22" = load double, double* %"s"
  %".23" = load double, double* %"p"
  %".24" = fmul double %".23", 0x3ff3333333333333   ; 1.2 in IEEE-754 hex
  %".25" = fadd double %".22", %".24"
  store double %".25", double* %"s"
  br label %"for_list_inc"

for_list_inc:
  %".28" = add i64 %".16", 1
  store i64 %".28", i64* %"__for_idx"
  br label %"for_list_header"

for_list_exit:
  %".31" = load double, double* %"s"
  ret double %".31"
}

Read the two side by side and the mapping is direct. The parameter is a handle, not a Python object. The loop is a counted loop with the length fetched once. The arithmetic — a float multiply and a float add — operates on double values in registers, with the constant 1.2 written as its raw IEEE-754 bit pattern 0x3ff3333333333333. There is no bytecode dispatch anywhere in this function. The calls that remain are the ones that touch the container, and they go to the Pyvorin runtime rather than CPython.

One sentence captures the win. CPython would execute this loop as hundreds of bytecodes per iteration; the IR above is a handful of instructions.

Runtime calls in the IR

The module header of a generated file is mostly declarations: several hundred declare lines for functions in the Pyvorin runtime libraries — nexus_list_create, nexus_rt_dot_product_float, nexus_dgemm_rowmajor and the rest. The generator declares the runtime surface the module might touch, and the linker resolves the needed symbols against the runtime .so files that ship inside the wheel. Operations that Python defines as method calls on objects — list indexing, string building, dict updates — lower to calls into these C runtimes, which we cover in Runtime libraries. When the generator cannot map an operation to a typed runtime call, it boxes the operands as CPython object handles and calls the CPython API instead. That interop path works, but it is slower, and it is one of the reasons object-heavy code sees smaller gains.

Verifying and optimising the module

Generation ends with a string of LLVM IR text. What happens next is short and strict, and each step is visible in the compiler source. The module is parsed with binding.parse_assembly, then checked with mod.verify() — LLVM's structural verifier, which rejects malformed IR with an exception rather than letting it reach the code generator. A pass builder is then configured at the compiler's optimisation level (3 by default) with size level 0, superword-level parallelism vectorisation enabled and an always-inliner pass, and the pipeline runs over the module in memory. Finally target_machine.emit_object(mod) produces the object bytes. The target machine itself is tuned to the host CPU: the compiler queries LLVM for the host CPU name and feature set, so the emitted code can use the vector instructions your actual processor supports.

# Compile one function and see the tier it reached
python -m pyvorin compile your_script.py --function total

# Ask why anything fell back
python -m pyvorin compile your_script.py --explain-fallback

You can reproduce the example above with these commands — the compile subcommand flags were captured from python -m pyvorin compile --help on the installed 1.0.9 build. For the curious, the 1.0.9 source also writes the most recently generated module to /tmp/current_ir.ll as a debug artefact; that file is how the IR quoted on this page was obtained.

What llvmlite is, and is not

llvmlite is not a Python compiler and does no optimisation of its own. It is a faithful binding layer: the IR text Pyvorin builds is ordinary LLVM IR, parseable by the llvm-as toolchain, and the optimisation pipeline that runs on it is LLVM's, not a Python reimplementation. This distinction matters for trust. When Pyvorin claims an optimisation pass ran, the pass in question is the same code that optimises C and Rust, not a bespoke approximation written for the product.

It is also worth saying what is not yet LLVM-backed. A separate research module, native/llvm_backend.py, exists for numeric kernels, but its own docstring is unambiguous: it is a proof of concept for a single kernel family, gated behind PYVORIN_LLVM_EXPERIMENTAL (default off), not wired into the router or CLI, and it may be removed without deprecation. The production path described on this page is the one every compiled function takes.

Honest limits

LLVM IR generation is the middle of a pipeline, not the whole story. The quality of the machine code depends on how much of the function could be lowered to typed operations; code that stays in boxed-object territory produces IR full of CPython API calls, which LLVM can schedule and register-allocate but cannot make fundamentally cheaper. And the IR stage has a hard rule above it: if the frontend cannot produce a correct lowering, the function never reaches this page's pipeline at all — it takes the fallback path described in Unsupported code and the fallback. Generation is fast, but it is not free, which is why the disk cache described in How local compilation works matters on cold starts.

Where to go next

Last reviewed 23 January 2026 against pyvorin-native 1.0.9: the IR quoted on this page is abridged output from a real compile of the shown function, obtained via the compiler's /tmp/current_ir.ll debug artefact; command flags were captured from python -m pyvorin compile --help on the installed build.