Compiler Pipeline Overview
How Pyvorin turns a Python function into native machine code — every stage from parsing to the wrapper that makes the result callable.
Published Jan 16, 2026
A Pyvorin-compiled function begins life as ordinary Python source and ends as machine code in a dynamically linked shared library, loaded into your running CPython process. Between those two points sit a parser, a compatibility gate, a type-inference pass, several dozen AST-level optimisation passes, an LLVM IR generator, LLVM's own optimiser, a linker invocation, and a ctypes wrapper that marshals Python objects into the native ABI. This page walks the whole path for Pyvorin Native 1.0.9 and names each stage after the module that performs it, so you can check every claim against the installed package.
The pipeline matters to users, not just compiler engineers, because each stage is also a failure mode with its own honest exit. When a stage cannot do its job safely, Pyvorin does not guess: it marks the function, records a reason, and keeps the code running under CPython semantics. Understanding the shape of the pipeline tells you where to look when pyvorin support says a function is only a partial candidate — and why that is a design feature rather than a bug.
The pipeline at a glance
The native compiler is an ahead-of-time, in-process compiler. Nothing is sent anywhere: source is parsed, analysed, lowered and linked on your machine, inside your process. The compile path contains no network code at all, which means the pipeline below is also a complete description of what happens to your source.
| # | Stage | Where it lives | Output |
|---|---|---|---|
| 1 | Parse | ast.parse in native_compiler.py | Python AST |
| 2 | Pattern detection | JSON/JSONL/CSV recognisers | Kernel substitution plan |
| 3 | Type inference | frontend/return_type_analysis.py | Logical types, arg hints |
| 4 | Compatibility analysis | frontend/compat_analyzer.py | Native vs compat split |
| 5 | AST optimisation passes | pyvorin.opt.* | Lowered, annotated AST |
| 6 | LLVM IR generation | NativeCodeGenerator | LLVM IR module |
| 7 | LLVM optimisation | llvmlite pass builder | Optimised IR |
| 8 | Native emission and linking | target machine + system linker | Shared library |
| 9 | Call bridge | ctypes wrapper, runtime .so set | Callable Python object |
Timing for each stage is collected by an internal profiler, and the stage names above are the real phase names you will see in tooling output. The rest of this page takes them in order.
Stage 1: parse and prepare the AST
Compilation starts with the standard library's own parser: ast.parse(source). Using CPython's grammar directly is a deliberate choice — the AST the compiler reasons about is exactly the AST CPython itself would build, so there is no second grammar to drift out of sync with the language.
Immediately after parsing, the compiler preserves a deep copy of the target function's original AST before any rewriting begins. That copy is not ceremonial. Later transforms rewrite generators into classes, desugar decorators and substitute kernel calls, and several safety checks — type inference for ctypes argument hints, detection of unsafe dynamic values — must see the source as the programmer wrote it, not as the optimiser reshaped it. Keeping the original tree costs a deep copy per compilation; skipping it would let pattern rewriting hide the very patterns the safety checks exist to catch. That trade-off is worth naming, because it is typical of the whole design: spend a little compile time to keep the reasoning honest.
Stage 2: pattern detection
Before any general-purpose lowering, recognisers look for whole-function idioms that have hand-written native implementations. A JSON transform recogniser, a JSONL scanner recogniser and a CSV scanner recogniser each analyse the target function; if the function matches, the pipeline routes it to a specialised native kernel through the morphic ETL lowering bridge rather than running it through the generic loop-by-loop path. Edit-distance and probabilistic-sketch rewrites happen here too, early enough that later safety checks see the kernel call, not the original dynamic-programming loop.
This stage explains something that surprises new users: two functions with identical control flow can compile through entirely different machinery depending on whether they match a recognised idiom. The fast paths are not a special mode you enable; they are pattern matches the compiler tries first.
Stages 3 and 4: types and compatibility
Type inference runs over the original AST and produces a map from variable names to logical types — int, float, string, list, dict — propagated through assignments in a fixpoint loop. Its output drives concrete decisions later: which parameters can be passed as unboxed 64-bit integers or doubles, what ctypes signature the wrapper should use, and which list element types the code generator should assume. Full detail lives in Type inference system; here it is enough to say that the compiler treats types as evidence to be gathered, not annotations to be demanded.
Compatibility analysis then decides, per function, whether the native path is sound at all. The walk flags async functions, yield, global statements, non-simple with and try shapes, unsupported imports and a list of smaller constructs. Functions that fail the gate are not rejected outright — they are classified and routed, which is the subject of AST analysis phase. Optional profile-guided optimisation also feeds in here: if you pass warm-up runs, the compiler collects loop trip counts, branch frequencies and argument/return type histograms, then uses those measurements to switch vectorisation, unrolling and parallelism on or off conservatively.
Stage 5: the AST optimisation passes
The transformed AST then passes through a long sequence of optimisation stages. These are real pass names, taken from a live compilation report of an ordinary numeric function:
Optimizations applied:
- escape_analysis
- loop_fusion
- pipeline_fusion
- vectorization_optimizer
- constant_folding_+_dce
- complete_loop_unrolling
- branchless_promotion
- pointer_specialization
- stack_allocation_promotion
- string_builder_promotion
- list-to-array_promotion
- append-to-array_promotion
- string_chain_to_fstring
- fstring_inliner
- rolling_window_optimizer
- sort_optimizer
- function_cloning__specialization
- kernel_substitution
- record_aggregate(acc)
The named passes deserve a word each, because they map directly onto where speedups come from. Escape analysis (extraction/escape_analysis.py) classifies each locally allocated list, dict or set as local — never leaving the function — or escaping. Local allocations can live on the native stack instead of the Python heap; that single distinction removes most allocation and reference-count traffic from tight loops. Loop fusion (opt/loop_fusion.py) merges adjacent range() loops with identical bounds when no data dependency crosses between them, halving loop overhead and improving cache reuse. Bounds-check elimination (opt/bounds_check_elimination.py) tracks value ranges through the function and proves indices safe — hoisting or deleting checks that LLVM cannot see through Python-level semantics; where it cannot prove safety it can version the loop into fast and slow variants, with a speculative path that deoptimises if the assumption breaks.
Vectorisation is a cost-modelled AST pass (opt/vectorization_optimizer.py): loops estimated above a trip-count threshold, or carrying an array-reduction pattern such as a dot product, are annotated for SIMD lowering; loops with breaks, nesting or calls are skipped. Parallelisation (opt/automatic_parallelization.py) targets reduction loops that are safe to split across a thread pool, and the parallel runtime library is linked only when the generated code actually uses it. Constant folding and dead-code elimination, complete loop unrolling, branchless promotion and the string passes (string_chain_to_fstring, string_builder_promotion) each remove one class of interpreted overhead. Function cloning and specialisation produces type-specialised copies of hot helpers.
A judgement call sits inside this list. Pyvorin runs many of its most aggressive passes on AST shape rather than on a fully lowered IR, which keeps the passes cheap and composable but forces each pass to be conservative: when a pass cannot prove its transformation is sound, it must leave the code alone. You can see the consequence in the bench flags — --no-vectorize, --no-parallel, --no-pgo exist precisely so you can isolate the contribution of each stage and confirm it on your own workload rather than trusting the aggregate.
Stages 6 and 7: LLVM IR generation and optimisation
The lowered AST is walked by the native code generator, which emits LLVM IR through llvmlite — the same LLVM binding family used by Numba. The generated module declares the runtime functions it will call (list creation, array arithmetic, kernels) and defines one LLVM function per Python function being compiled. Unboxed scalar parameters become i64 or double; Python objects cross the boundary as opaque handles and are only materialised when the semantics require it.
The IR then enters LLVM's own pipeline. The compiler parses the module, verifies it, and runs a pass builder configured from your opt_level (default 3) with SLP vectorisation enabled and an always-inline pass. LLVM does here what LLVM is good at: instruction-level scheduling, register allocation, and machine-specific instruction selection. Pyvorin's job is to hand LLVM a module where the Python semantics have already been resolved; LLVM's job is to make the resulting instructions fast for your exact CPU.
Stage 8: native emission and linking
The target machine emits a native object file, which is linked with the system compiler against Pyvorin's runtime libraries — the list, dict, string, float, integer, parallel, CSV, JSON, regex and numeric kernel runtimes among them. Load order matters here for a mundane reason: on glibc, the first symbol loaded into the global namespace wins, so the linker command places the dictionary runtime ahead of libraries that carry weak stubs for the same symbols. Fresh libraries are linked into a project-local temporary directory rather than the system temp, so they remain dlopen-able even where /tmp is mounted noexec. Details like that are the difference between a demo and a product.
The finished shared library is cached on disk — keyed by a hash of source, function name, options and runtime/compiler timestamps, with an LRU bound of 500 entries and per-entry checksums. Recompilation of unchanged code is a cache hit, which is what makes repeated imports cheap.
Stage 9: the call bridge
The final stage turns the linked library into something that looks like an ordinary Python callable. The wrapper boxes arguments into the native ABI, calls the function pointer through ctypes, unboxes the result, and — critically — validates its assumptions on the way in. If an argument turns out not to fit the inferred native signature, or a runtime guard fails, the wrapper diverts the call to a lazily compiled copy of the original Python and your program continues with correct semantics. That mechanism is the subject of Guard and deoptimisation; the short version is that the bridge never lets a wrong assumption become a wrong answer.
Failure modes and honest exits
Each stage has its own way of declining work, and the differences are instructive. Parse failure is a hard stop — there is no AST to reason about. A failed compatibility gate is a routing decision: the function is marked for compatibility execution and, in partial mode, its supported islands still compile. A failed type inference is a retreat to the object representation, not an error. A verifier failure inside LLVM — a malformed module — is a genuine compiler bug by construction, which is why the module is verified before optimisation and any failure there is loud. And a guard failure at runtime is not a compile-time event at all; it is the call bridge correcting an assumption, as covered in Guard and deoptimisation.
The pattern across all five exits: nothing silently produces wrong code. Every decline is recorded with a reason, surfaced through support, inspect and explain, and honoured at runtime. This is the single most important property of the pipeline, and it is the reason the benchmark story can be honest about the sixteen workloads that ran slower — the compiler measured, declined, or reported rather than papered over.
Seeing the pipeline yourself
Every stage above can be observed from the CLI on your own files:
# Per-function support tiers and fallback reasons
python -m pyvorin support your_script.py
# Compatibility summary with a risk level
python -m pyvorin inspect your_script.py
# The optimisation passes that fired, per function
python -m pyvorin explain your_script.py
# The actual LLVM IR for a file that compiles natively
python -m pyvorin inspect your_script.py --ir
On a two-function numeric script, inspect reports both functions as COMPILED_FULL, a risk level of NONE, and the verdict "All functions compile natively. Ready for benchmark." That is the whole pipeline agreeing with itself — and when it does not agree, these same commands tell you which stage dissented and why.
Where to go next
- AST analysis phase — how functions are selected as compilation candidates, and what disqualifies them.
- Type inference system — how dynamic Python acquires the static types native code needs.
- LLVM IR generation — the lowering stage in detail, and what the generated IR looks like.
- Guard and deoptimisation — what happens when a runtime type breaks a compile-time assumption.
Last reviewed 16 January 2026 against Pyvorin Native 1.0.9 (installed package and source tree). Every stage name and module reference on this page was confirmed against the installed package; CLI output was captured by running the documented commands locally.