The LLVM backend¶
The llvm backend lowers the shared typed-SSA IR to LLVM IR text, which llvmlite assembles to a native object in process — no external llc. It consumes the same IR as the C and QBE backends and follows the platform C ABI, so its objects link against the same pp_<name> export-wrapper translation unit — no glue, no forward declarations (spec §9.1).
Its output is held to the same rule as QBE: byte-identical to C wherever the arithmetic is exact. That equivalence is the backend's correctness oracle.
It is modelled on the QBE backend — both are typed-SSA text emitters below C, and the package mirrors QBE's layout (types · convert · arithmetic · arrays · instructions · assemble). But it diverges from QBE in one decisive way: it does no hand-written optimization (see below).
Pipeline¶
IR Module
→ emit_module → LLVM IR text (.ll)
→ llvmlite.binding: parse_assembly → verify → target machine (reloc=pic, opt=2)
→ the -O2 module pass pipeline → emit_object (all in process)
→ link with the C export wrappers (+ ipow runtime if used) → .so / .dylib
LLVMBackend implements the backend protocol: emit returns the IR text, compile_object drives llvmlite.binding to produce the .o, and is_available is true when llvmlite is importable and a C compiler is on PATH (the export wrappers and the ipow runtime are C). Its source_ext is .ll.
Select it per build:
or with the POSTC_BACKEND=llvm environment variable or the backend="llvm" API argument. The default backend remains c.
llvmlite is an optional dependency
Install it with the llvm extra (pip install postc[llvm], or
uv sync --extra llvm for a dev checkout). The backend imports llvmlite
lazily — only inside compile_object and is_available — so it registers
without error when the extra is absent; it simply reports itself
unavailable until llvmlite is present.
Coverage¶
LLVM covers the same scalar + array kernel subset as QBE: int/float/bool arithmetic including floor-div, mod, float and integer **, abs, casts, and select; array load/store/dim/stride/alloc and len(); sub-word integers; mutable locals and output-by-pointer stores; and the NumPy generalized-ufunc inner loop. What it does not lower stays on the C path:
| Not lowered | Why |
|---|---|
Complex64 / Complex128 |
not modelled (kept on C, which has native complex) |
Float16 |
no half-float arithmetic in the emitter |
Str / Bytes |
pointer types not modelled |
| CPython extension modules | C-API registration glue is C by nature |
The backend's capabilities are the defaults — complex=False, ext_module=False, float16=False. The build driver rejects what a backend cannot do; it does not silently route around it. postc build z.py --backend llvm on a Complex128 program fails with error: dtype Complex128 not supported by the LLVM backend, and --ext-module fails with error: the 'llvm' backend does not build CPython extension modules. Choosing the C backend for those is the user's call to make, and the diagnostic says so.
Fail loud, never miscompile
Any IR construct the backend cannot lower raises LLVMUnsupportedError,
so the build fails clearly rather than silently changing behavior — an
unsupported dtype (llvm_type), an aggregate GetField/SetField, or an
unhandled instruction all raise. This is the project's guiding rule: reject
unsupported semantics, never miscompile.
Type mapping¶
LLVM's integer types are exact-width, so each POST dtype maps to its natural type:
| POST Python dtype | LLVM type |
|---|---|
Bool |
i1 |
Int8 / UInt8 |
i8 |
Int16 / UInt16 |
i16 |
Int32 / UInt32 |
i32 |
Int64 / UInt64 |
i64 |
Float32 |
float |
Float64 |
double |
Because sub-word integers are stored at their true width, add/sub/mul wrap at that width natively — matching POST's truncate-to-width semantics with no explicit narrowing pass (where QBE, keeping a 32-bit w, must re-narrow). Mixed-type and sub-word comparisons still promote their operands to C's usual-arithmetic-conversion common type first. Signedness is carried by the operation (sdiv vs udiv, sext vs zext, fptosi vs fptoui), since LLVM integer types are sign-agnostic.
Constants are emitted in LLVM's hex bit-pattern form (double 0x...): it is exact for any value, whereas LLVM rejects an inexact decimal float literal.
ABI and declarations¶
Float ** routes through libm (powf/pow); integer and unsigned ** call __pp_ipow_i64 / __pp_ipow_u64 — the shared IPOW_RUNTIME_C unit (an extern copy of the C backend's static ipow helpers, bit-identical), emitted by runtime_sources only when a module uses integer **. This runtime lives in backends/runtime.py, shared with QBE so neither backend imports the other.
LLVM IR requires a declare for every called function. Each callee's signature is recorded as it is emitted — the internal libm helpers (floor, fmod, fabs, … used by floor-div / mod / abs) from a fixed table, and every other callee (libm called from POST code like exp/sqrt, and cross-module POST functions) synthesised from its call site. emit_module then emits a declaration for every symbol the module does not itself define.
No hand-written passes¶
This is where the backend departs from QBE. QBE does register allocation but no loop optimization, so the QBE backend hand-writes its own IR-level passes (local CSE, metadata LICM, ufunc strength reduction, kernel fusion, loop strength reduction, ×4 unrolling) to stay competitive. LLVM has a world-class optimizer, so this backend takes the opposite route:
- Emit naive IR and let
-O2optimize. Array accesses recompute their addresses, loops are plain counted loops — LLVM's pass pipeline does the LICM, strength reduction, unrolling, and vectorization. This deletes the entire pass layer (there is nopassesmodule). - One hint the optimizer can't infer:
!tbaa. The naive-IR bet needs exactly one piece of metadata. An__pp_arraydescriptor and the element buffer it points at are separate objects, but-O2can't prove an element store (out[i] = …) doesn't clobber the struct, so LICM won't hoist the per-access descriptor loads — it reloadsdata/offset/shape/stridesevery iteration (~3× slower on a bandwidth-bound loop). Tagging descriptor loads and element loads/stores with distinct TBAA types — the same thing clang emits — supplies the disjointness and the loads hoist. Unlike!invariant.loadit asserts no constant memory, so it stays correct when the same array is passed twice or a ufunc rebinds a view's data pointer between calls. This gap was caught by the cross-backend benchmark'ssquarekernel. alloca+mem2regfor SSA construction. Reassigned locals get an entry-blockallocawith naive load/store, whichmem2reg/SROA in-O2promote to registers, constructing SSA form — so the emitter never writes aphiby hand. (Scalar outputs store directly through the caller's pointer, which is their slot.)- Strict IEEE floats — no fast-math.
-O2never reassociates floating-point withoutreassoc/fast, so reductions keep their exact serial order and results stay bit-identical tocc -O2. The backend sets no fast-math flags.
Correctness¶
Verification rests on the byte-identical oracle: every kernel is built through the C and LLVM backends and driven through the real NumPy generalized-ufunc ABI via ctypes, and the two native outputs must agree bit-for-bit. The LLVM backend is held to the same golden-program corpus as C and QBE — it joins the parity oracle automatically by registering as available. When two backends behave identically-wrongly, the test itself is at fault.
Performance¶
Because it emits naive IR and lets -O2 do the work, the LLVM backend runs at parity with cc -O2 — geometric mean ≈ 1.01× across the kernel suite (square, dot, gaussian, matmul), i.e. within measurement noise — and so about 15% faster than QBE, whose hand-rolled passes this backend deliberately skips.
| Kernel shape | vs cc -O2 |
|---|---|
| Straight-line arithmetic | ~parity |
Single reductions (dot, gaussian) |
~parity |
Nested-loop matmul |
~parity |
matmul — QBE's low point at ~1.3× slower, because its inner loop is strength-reduced but not unrolled — runs at parity here, since LLVM's optimizer unrolls and schedules it like the C compiler does. The residual differences are measurement noise. Neither cc -O2 nor LLVM at -O2 emits SIMD or FMA for FP reductions, since both would reorder the summation and change the bits.
Reproduce the numbers
See Benchmarking for the harness (the
postc-llvm runtime and its geomean-vs-C line) and the methodology.
See also¶
- Writing a backend — the protocol LLVM implements.
- The QBE backend — the sibling text emitter this one is modelled on, and the contrast in optimization strategy.
- The C backend — the correctness oracle both are measured against.
- IR reference — the input all three backends consume.