Skip to content

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 (emitcompile_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:

from postc.compiler.backends import register
register(MyBackend())

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

  1. Implement the seven Backend members; model emit on the C backend and the IR reference.
  2. register(MyBackend()) in-tree, or ship a postc.backends entry point.
  3. Green differential tests: semantic vs CPython and parity vs C.
  4. Declare Capabilities accurately; 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.