Writing a Backend¶
A postc backend is a small object that turns one typed IR Module into a native object file. Every backend consumes the same IR, so a program compiles identically through any of them — and the differential oracle enforces that they agree. This guide walks the whole path: implement the Backend protocol, register it (in-tree or as a separate PyPI package), prove it against the oracle, and declare what it can't lower so the driver rejects unsupported builds cleanly.
The contract lives in postc.compiler.backends.base; the registry in postc.compiler.backends. The C backend is the reference implementation and the smallest complete example — read it alongside this page.
1. Implement the Backend protocol¶
Backend is a @runtime_checkable Protocol — there is no base class to subclass. Any object with the right attributes and methods is a backend.
| Member | Kind | Contract |
|---|---|---|
name |
str |
Registry key: "c", "qbe", "llvm", … Last registration under a name wins. |
source_ext |
str |
Extension the driver gives your emitted source file: ".c", ".ssa", ".ll", … |
capabilities |
Capabilities |
What you can lower (see §4). |
emit(module) |
-> str |
Lower one IR Module to backend source text. |
compile_object(source, obj, tc) |
-> None |
Turn the emitted source file into an object file obj. |
runtime_sources(modules) |
-> list[RuntimeUnit] |
Extra C units your objects link against (may be empty). |
is_available() |
classmethod -> bool |
True when your external tools are present on this machine. |
emit(self, module: Module) -> str returns the full translation unit as text — nothing more. The driver writes it to work_dir/<stem><source_ext> for you, so emit never touches the filesystem. Walk module.functions (each a Function or UFunc) and, if you generate cross-module declarations, module.dep_modules — the resolved POST units this one imports from. Model the traversal on the C backend's emit_module; the IR vocabulary is documented in the IR reference.
compile_object(self, source: Path, obj: Path, tc: Toolchain) -> None reads the source file the driver just wrote and produces obj. It does not write source. Drive external tools through the passed Toolchain:
@dataclass(frozen=True)
class Toolchain:
cc: str = "cc"
qbe: str = "qbe"
cflags: tuple[str, ...] = ("-O2", "-fPIC") # composed by the driver
def run(self, cmd: list[str]) -> None: ... # raises ToolchainError on non-zero
def compile_c(self, source: Path, obj: Path) -> None: ... # C TU -> .o
If your backend emits C, one line suffices: tc.compile_c(source, obj). If it emits an assembler-bound IL, do it in two steps — the QBE backend runs qbe to produce .s, then hands that to the C compiler:
def compile_object(self, source: Path, obj: Path, tc: Toolchain) -> None:
asm = source.with_suffix(".s")
tc.run([tc.qbe, "-o", str(asm), str(source)]) # IL -> assembly
tc.compile_c(asm, obj) # assembly -> object
A tool with no dedicated Toolchain field (say llc) is simply invoked by name via tc.run([...]) and gated in is_available. Any non-zero exit raises ToolchainError, which the driver reports as a BuildError.
runtime_sources(self, modules: list[Module]) -> list[RuntimeUnit] declares extra C support units your objects need linked in. Each RuntimeUnit(stem, source) is a C string — the driver always compiles these as C, whatever your source_ext. Return [] if you need none; return a unit only when the program actually uses the feature. QBE, for example, ships an integer-** helper only when a module raises an integer power:
def runtime_sources(self, modules: list[Module]) -> list[RuntimeUnit]:
if module_uses_ipow(modules):
return [RuntimeUnit("pp_qbe_runtime", IPOW_RUNTIME_C)]
return []
is_available(cls) -> bool is a classmethod reporting whether this machine has your tools. available_backends() and postc backends filter on it; an unavailable backend stays registered but is skipped for building. Probe with shutil.which:
@classmethod
def is_available(cls) -> bool:
return shutil.which("qbe") is not None and has_c_compiler()
A worked example¶
The smallest backend that actually builds end-to-end delegates emission to the C backend, so it exercises the full driver path (emit → compile_object → link). This is the exact shape the out-of-tree test uses:
from pathlib import Path
from postc.compiler.backends import Capabilities, RuntimeUnit, Toolchain
from postc.compiler.backends.base import has_c_compiler
from postc.compiler.ir import Module
class MyBackend:
name = "mybackend"
source_ext = ".c" # driver writes emitted text to *.c
capabilities = Capabilities() # scalar/array kernels only (see §4)
def emit(self, module: Module) -> str:
from postc.compiler.backends.c import emit_module
return emit_module(module, dep_modules=module.dep_modules)
def compile_object(self, source: Path, obj: Path, tc: Toolchain) -> None:
tc.compile_c(source, obj)
def runtime_sources(self, modules: list[Module]) -> list[RuntimeUnit]:
return []
@classmethod
def is_available(cls) -> bool:
return has_c_compiler()
Swap the body of emit for your own IR-walking code generator and set source_ext to your target's extension; the other four members stay as shown.
2. Register it¶
In-tree, call register with an instance. Last registration under a name wins, so this also lets you override a built-in:
Out-of-tree, ship your backend as its own PyPI package and expose it through the postc.backends entry-point group. postc discovers it on import — no code changes to postc itself:
# pyproject.toml of your plugin package
[project.entry-points."postc.backends"]
mybackend = "my_postc_backend:MyBackend"
The entry point may resolve to a class, a factory callable, or a ready-made instance — postc calls it if it's callable, otherwise registers it as-is. A plugin that raises on load is skipped with a UserWarning; one broken plugin never breaks importing postc. Confirm discovery with:
postc backends # lists name, availability, source_ext, caps
postc backend-info mybackend # capabilities + tool status for one backend
3. Prove it agrees¶
A new backend is only correct if it produces the same answers as everything else. POST Python is a typed subset of Python, so any POST function also runs under CPython — which gives postc.testing.differential two oracles to hold your backend to. It builds and calls pp_<func> through each backend via ctypes and returns every result by name:
from postc.testing import differential
def test_mybackend_agrees(tmp_path):
src = (
"from postyp import Float64\n"
"def triple(x: Float64) -> Float64:\n"
" return x * 3.0\n"
)
got = differential(src, "triple", (2.0,), tmp_path,
backends=("c", "mybackend"))
# semantic: compiled output must match CPython
assert got["mybackend"] == got["cpython"]
# parity: must match the C backend byte-for-byte where arithmetic is exact
assert got["mybackend"] == got["c"]
- Semantic — the compiled result must equal CPython's for the same inputs.
- Parity — where the arithmetic is exact (integers, bit-identical float ops), it must match the C backend byte-for-byte.
differential handles scalar POST functions; it derives the ctypes signature from the postyp annotations and raises TypeError on a non-scalar parameter. Array/ufunc kernels are exercised against the NumPy ABI in the integration suite. Run yours across the example corpus, exactly as the built-in QBE backend is verified. See Testing for the full setup.
Prove the driver path too
Beyond differential, build a real artifact with
build_source(src, output=out, backend="mybackend"), ctypes.CDLL it, and
call the pp_<name> symbol. That confirms compile_object, runtime units,
and linking all fit together — not just codegen.
4. Declare capabilities¶
Capabilities is how a backend tells the driver what it cannot lower, so unsupported builds are rejected up front instead of failing deep in codegen:
@dataclass(frozen=True)
class Capabilities:
complex: bool = False # Complex64/128 arithmetic
ext_module: bool = False # can back a CPython extension (needs C-API glue)
float16: bool = False # native float16 arithmetic
The defaults are conservative: a bare Capabilities() claims only the scalar/array kernel subset, which is why the QBE backend uses it. The C backend, the reference, sets Capabilities(complex=True, ext_module=True).
The driver gates on these before building. Requesting a CPython extension from a backend that can't glue one, for instance, fails cleanly rather than emitting broken code:
build_file(src, ext_module=True, backend="qbe")
# BuildError: the 'qbe' backend does not build CPython extension modules
Set every flag accurately, and have emit raise a BackendUnsupportedError on any construct outside your declared set (as QBE does for complex numbers). Nothing routes those programs to a capable backend for you — the build fails with your message, and the user picks a backend that can do the job. That is deliberate: a compiler that silently swapped a backend out from under you would be choosing your performance and your ABI on your behalf. postc backend-info prints your declared capabilities for callers to inspect.
Checklist¶
- Implement the seven
Backendmembers; modelemiton the C backend and the IR reference. register(MyBackend())in-tree, or ship apostc.backendsentry point.- Green
differentialtests: semantic vs CPython and parity vs C. - Declare
Capabilitiesaccurately; raise on what you can't lower.
See also: the Backend protocol reference, the built-in C and QBE backends, and the Toolchain & C ABI.