Skip to content

The Backend Protocol

Every postc backend consumes the same typed SSA IR and turns it into a native object file. A program therefore compiles identically through any backend — the differential test suite enforces that they agree. This page documents the Backend protocol, its supporting types, and the registry that keys backends by name and selects one at runtime.

Everything here lives in postc.compiler.backends. To implement a backend, see Writing a backend.

The Backend protocol

Backend is a @runtime_checkable typing.Protocol: any object with the right attributes and methods qualifies — no base class to inherit. A backend carries three attributes and four methods.

Attribute Type Role
name str Registry key, e.g. "c", "qbe", "llvm"
source_ext str Extension of emitted source, e.g. ".c", ".ssa"
capabilities Capabilities What the backend can lower (see below)
def emit(self, module: Module) -> str:
    """Lower one IR module to backend source text."""

def compile_object(self, source: Path, obj: Path, tc: Toolchain) -> None:
    """Turn an emitted source file into an object file."""

def runtime_sources(self, modules: list[Module]) -> list[RuntimeUnit]:
    """Extra C runtime units this backend needs for *modules* (may be empty)."""

@classmethod
def is_available(cls) -> bool:
    """True when this backend's external tools are present on this machine."""
  • emit does the code generation: one IR Module in, backend source text out. The driver writes it to a file named with source_ext.
  • compile_object assembles that source into a .o. It receives a Toolchain so it can invoke cc, qbe, and friends without hard-coding tool names or flags.
  • runtime_sources declares any extra C support code the emitted module needs linked in (returned as RuntimeUnits). It may return an empty list.
  • is_available is a classmethod reporting whether the backend's external tools exist on this machine — used by available_backends() and the postc backend-info command.

Capabilities

A frozen dataclass describing what a backend can lower. The driver checks these before building so an unsupported program fails early with a clear message rather than deep inside code generation.

@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 built-in backends advertise:

Backend complex ext_module float16
c
qbe
llvm

Toolchain

A frozen dataclass bundling the external tools and flags used to turn backend source into an object, plus two helpers.

@dataclass(frozen=True)
class Toolchain:
    cc: str = "cc"
    qbe: str = "qbe"
    cflags: tuple[str, ...] = ("-O2", "-fPIC")

    def run(self, cmd: list[str]) -> None: ...
    def compile_c(self, source: Path, obj: Path) -> None: ...
  • cflags is the full flag list placed before -c — optimisation, PIC, user flags, NumPy includes — composed by the driver.
  • run executes a command, capturing output, and raises ToolchainError on a non-zero exit.
  • compile_c compiles one C translation unit to an object via cc + cflags. Every backend's runtime_sources are compiled as C, so a backend's compile_object typically calls run for its own tool and lets the driver use compile_c for the runtime units.

RuntimeUnit

A C source unit a backend needs linked in — always compiled as C, whatever the backend's own source_ext is. The QBE backend, for example, ships an integer-power helper and export wrappers this way.

@dataclass(frozen=True)
class RuntimeUnit:
    stem: str    # base filename for the emitted .c
    source: str  # the C source text

The registry

The package __init__ holds a private name→backend map and exposes:

Function Returns / raises Purpose
register(backend) Register backend under its name (last wins)
get_backend(name) Backend / UnknownBackendError Look up by name
all_backends() list[str] Every registered name, sorted
available_backends() list[str] Names whose is_available() is true
from postc.compiler.backends import get_backend, available_backends

backend = get_backend("qbe")

UnknownBackendError subclasses LookupError and carries .requested and .available, so callers can report the valid choices.

Registration and plugin discovery

On import the package registers the three built-ins — CBackend, QBEBackend, and LLVMBackend — then discovers third-party backends via the postc.backends entry-point group:

# a plugin package's pyproject.toml
[project.entry-points."postc.backends"]
spirv = "postc_spirv:SPIRVBackend"

An entry point may resolve to a Backend class, a factory callable, or a ready-made instance; a callable result is invoked, otherwise the object is registered as-is.

Broken plugins are skipped

A plugin that fails to load must never break importing postc. Each is loaded in a try/except; on failure it is skipped with a warnings.warn (emitted even under -W error) naming the entry point and the exception.

Runtime backend selection

The driver and CLI resolve which backend to build with in a fixed precedence:

  1. Explicit argumentpostc build --backend qbe, or backend= in the API.
  2. POSTC_BACKEND environment variable.
  3. Default "c".

An unknown name (including one coming from POSTC_BACKEND) is rejected against all_backends() with a message listing the valid choices.

postc backends                 # list registered backends + availability
postc backend-info qbe         # capabilities and tool status for one backend
POSTC_BACKEND=qbe postc build kernel.py
postc build kernel.py --backend c   # explicit arg wins over the env var

See Writing a backend to implement your own, and the toolchain reference for how the external tools are wired.