architecture Advanced

Native Code Generation

How pyvorin-native 1.0.9 turns LLVM IR into a loadable shared object: object emission, linking against the runtime, and when SIMD vectorisation fires.

Published Jan 28, 2026

A Pyvorin-compiled function ends its life as a shared object on disk, linked against the runtime libraries that ship inside the wheel. Between the LLVM IR described in LLVM IR generation and that file sits a short, concrete sequence: optimise the module, emit an object file with LLVM, link it with gcc, and load the result with ctypes. This page walks that sequence, then turns to SIMD vectorisation — where it fires, where it refuses to, and what the honest performance ceiling looks like.

Everything here was read from the pyvorin-native 1.0.9 compiler source and the installed package on this host, and the SIMD commands shown were run as written.

Object emission

After the module passes LLVM's verifier, the compiler runs the optimisation pipeline in memory. The pass builder is configured with the compiler's speed level (3 by default, 0 for code size), superword-level parallelism vectorisation switched on, and an always-inliner pass; the pipeline then runs over the whole module. Object emission is one call: target_machine.emit_object(mod) hands back a buffer of machine-code bytes for the host architecture.

The target machine deserves a mention because it explains why the same source can produce different code on different servers. At construction the compiler asks LLVM for the host CPU name and its feature set, and builds a target machine tuned to that exact processor with position-independent code and the default code model. If CPU detection fails, it falls back to a generic CPU with a conservative feature string (+avx2,+bmi,+popcnt,+sse4.2) — code that runs everywhere, accelerates less. The engineering judgement is the conservative fallback: emitting code for a CPU you could not identify risks an illegal-instruction crash in production, which is a worse outcome than leaving some vector performance unused.

The object bytes are written to a file under a compile-temporary directory inside the cache tree rather than the system temp directory. That choice exists because many hardened hosts mount /tmp noexec, and a freshly linked library cannot be dlopen'd from a noexec filesystem. It is the kind of detail that never appears in marketing copy and regularly saves an afternoon.

Linking against the runtime

The linker command is built per platform — gcc -O3 -shared -fPIC -pthread on Linux, a Mach-O bundle on macOS, a MinGW shared library on Windows — with the runtime libraries appended. The compiler assembles the list deliberately, in an order that matters. dict_runtime.so is placed before nexus_list_runtime.so because on glibc, with RTLD_GLOBAL loading, the first definition of a symbol wins; a weak dict stub loaded first would shadow the strong implementation in the dict runtime. Newer numeric kernel libraries are linked before older ones so the current ABI wins for symbols both export. Two $ORIGIN-relative rpath entries let transitive runtime dependencies resolve regardless of the working directory, and -lm -pthread round out the command. If the link step fails, compilation fails loudly with the linker's stderr — it never falls back to silently shipping an unoptimised artefact.

The output is a real .so. You can inspect it with ordinary tools, and the compiler itself loads it with ctypes, resolving the compiled function's symbol and calling it through a typed wrapper. Compiled results are cached in the disk compile cache, keyed by a hash of source, function name, options and toolchain state, so this whole pipeline normally runs once per unique function rather than once per call.

Three layers of SIMD

"SIMD" in pyvorin-native is not one mechanism but three, stacked by abstraction level.

The AST pass. Before IR generation, a vectorisation pass walks the syntax tree and marks eligible for loops with metadata. The cost model is deliberately simple: a loop over range() whose trip count can be estimated above roughly a thousand iterations, or one carrying an array-reduction pattern such as a sum or dot product. Loops containing break, nested loops or function calls are skipped outright. Eligible loops are annotated with a vector width — 4 by default, 8 for trip counts around ten thousand or more — and an unroll factor of 2 above five thousand iterations. The annotation is a hint to the backend, not a guarantee; the metadata says "this loop is worth the vector unit's time".

The IR layer. A separate vectorisation module builds LLVM vector types directly through llvmlite: <4 x i64>, <8 x i32>, <4 x double> and wider AVX-512 shapes such as <16 x i32>, with intrinsics targeting SSE, AVX/AVX2 and AVX-512. The same module provides explicit decorators for marking functions as vectorisation targets. Meanwhile the standard optimisation pipeline — running at speed level 3 — has SLP vectorisation enabled, so straight-line code with parallel structure gets a second chance even without the AST hint.

The kernel registry. The third layer is not generated at all. A registry of hand-written SIMD kernels ships with the package, covering reductions, element-wise arithmetic, comparisons, group-by counts and string batch operations for concrete dtype pairs. The registry is gated on CPU detection: on a host without AVX2, simd-info reports the registry unavailable. On this host it reports 81 kernels.

# List the SIMD kernels your CPU can use
python -m pyvorin simd-info

# Machine-readable form for CI
python -m pyvorin simd-info --json
Pyvorin SIMD Kernel Registry
AVX2 detected: yes

Registered kernels (81):
  • add                   [float32]
  • add                   [float64]
  • add                   [int32]
  ...
  • sum                   [float64]
  • sum                   [int64]

You can isolate each layer's contribution when benchmarking: --no-vectorize, --no-parallel and --no-pgo are flags on the bench, compile and support subcommands, captured from --help on the installed build. Running a workload with and without vectorisation is the cleanest way to learn whether your hot loop is in the vectorisable set.

When vectorisation does not fire

The eligibility rules are strict, and the strictness is the point. A loop that exits early with break cannot be restructured into independent vector lanes without changing semantics. A nested loop's iteration space is harder to prove independent. A function call in the body may mutate shared state. The compiler declines all three rather than speculate and risk a wrong answer. Loops over containers other than range() — iterating a list directly, for instance — take the indexed-runtime path you saw in the generated IR, which is fast but scalar.

None of this is a defect. Vectorisation is a narrow accelerant applied to a narrow shape of code; the payoff elsewhere comes from removing bytecode dispatch, not from the vector unit. Treating it as the whole story is how benchmark pages end up quoting the 272x result as if it were typical. It is not; it is the head of a distribution whose median sits near 2.2x, and the honest description of the pipeline is the one that shows both ends.

The honest ceiling

The benchmark suite measured on the same product tells you where this pipeline's output lands. Sixty-six of 71 workloads completed through the compiler: a geometric mean of 3.33x, a median of 2.21x, and a best case of 272x on a trapezoidal-integration loop — precisely the tight numeric loop that vectorises cleanly. The sixteen workloads that ran slower than CPython are just as instructive. Web request handling averaged 0.42x, database operations 0.33x, parsing 0.31x and stdlib-heavy code 0.27x. Those shapes — code that spends its life inside C library routines or building and discarding millions of small Python objects — leave little interpreted-loop work for any compiler to remove, and the compiled path's overheads can show through. The worst case in the whole suite, a particle-movement simulation at 0.01x, is an extreme example of that shape.

The right mental model is a trade. You pay a small fixed cost — guards, dispatch checks, the FFI boundary — on every compiled call, in exchange for the removal of bytecode dispatch in the body. When the body is long, hot and numeric, the trade is overwhelmingly favourable. When the body is a handful of library calls wrapped in object churn, it is not. Both outcomes are measured, published, and reproducible with the commands on this page.

Where to go next

Last reviewed 27 January 2026 against pyvorin-native 1.0.9. Object-emission and linking details were read from the installed compiler source; simd-info output quoted was produced on this host; benchmark figures are from the suite artefact dated 19 July 2026 as reported on the benchmarks page.