Skip to content

Getting started

This walkthrough takes a small POST Python module from source to a native shared library you can call three ways: through the stable C ABI with ctypes, and as real numpy.ufunc objects from an importable extension module. Every command below is runnable as shown.

If you have not set up the toolchain yet, start with Installation. You need a C compiler for every backend, plus the qbe binary for the QBE backend and llvmlite for the LLVM backend. Check what postc can see:

postc backends
c       available     emits .c     caps: complex, ext_module
llvm    available     emits .ll    caps: (base)
qbe     available     emits .ssa   caps: (base)

1. Write a kernel module

POST Python is a typed subset: every parameter and local is annotated, and there is no reflection, closures, or *args/**kwargs. Kernels are marked with two Numba-compatible decorators from postc:

  • @vectorize — a scalar element-wise kernel (signature ()->()).
  • @guvectorize(types, layout) — a generalized ufunc with a NumPy layout signature such as "(n),(n)->()".

Save this as kernels.py:

from postyp import Array, Float64
from postc import vectorize, guvectorize
from postc.math import exp


@vectorize
def square(x: Float64) -> Float64:
    return x * x


@vectorize
def gaussian(x: Float64, mu: Float64, sigma: Float64) -> Float64:
    z: Float64 = (x - mu) / sigma
    return exp(-0.5 * z * z) / (sigma * 2.5066282746310002)


@guvectorize([], "(n),(n)->()")
def dot(a: Array[Float64], b: Array[Float64], out: Array[Float64]) -> None:
    result: Float64 = 0.0
    for i in range(len(a)):
        result += a[i] * b[i]
    out[0] = result

Types come from postyp (Float64, Int64, Bool, Array[...], …). postc.math re-exports the scalar libm functions; exp here lowers to the native exp() call when compiled.

Test before you compile

The decorators leave your functions callable in the plain interpreter — they wrap a NumPy broadcast loop — so you can validate semantics with no compiler in the loop. python kernels.py runs any __main__ block, square(3.0) returns 9.0, and dot([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]) returns 32.0. Every backend is held to this CPython behaviour as the differential oracle.

2. Check it

postc check verifies the file against the POST Python subset without compiling. Clean input prints nothing and exits 0; violations are printed with source context and the exit code is 1.

postc check kernels.py

3. Build it

postc build compiles the entry file — and every POST module it imports — through the selected backend, then links one shared library. The backend is chosen at runtime: --backend, else $POSTC_BACKEND, else c.

=== C backend

postc build kernels.py --backend c --emit-header
kernels.dylib
kernels.h

The output is kernels.dylib on macOS (.so on Linux), next to the source. --emit-header writes the C99 ABI header alongside it; --emit-manifest writes a machine-readable JSON description of the exports.

=== QBE backend

postc build kernels.py --backend qbe -o kernels_qbe.dylib

The QBE backend lowers the int / float / bool + array kernel subset (and the ufunc loop) through qbe. Complex numbers and the CPython extension glue always stay on C, so qbe reports caps: (base) — see backend-info for a backend's exact capabilities.

Both artifacts expose the same stable C ABI (spec §9.1): each public export gets a thin C function named pp_<name> with the export's exact signature. That contract is identical whichever backend produced the library.

The emitted header (kernels.h) shows the surface:

double pp_square(double x);
double pp_gaussian(double x, double mu, double sigma);
void pp_dot(__pp_array* a, __pp_array* b, __pp_array* out, int64_t pp_dim_n);

4. Call it

From C or ctypes — the pp_<name> ABI

Scalar exports map straight onto C primitives, so ctypes can call them directly. This works against either backend's library:

import ctypes

lib = ctypes.CDLL("./kernels.dylib")   # or ./kernels_qbe.dylib

lib.pp_square.argtypes = [ctypes.c_double]
lib.pp_square.restype = ctypes.c_double
lib.pp_gaussian.argtypes = [ctypes.c_double] * 3
lib.pp_gaussian.restype = ctypes.c_double

print(lib.pp_square(3.0))                 # 9.0
print(lib.pp_gaussian(0.0, 0.0, 1.0))     # 0.3989422804014327

Any C/Rust/Zig/Julia caller links the library and calls the pp_* symbols the same way; the header and --emit-manifest JSON describe them.

As numpy.ufunc — build an extension module

Passing --ext-module links an importable CPython extension that registers every public ufunc with NumPy. This target is C only, so build it with the C backend:

postc build kernels.py --ext-module --module-name kernels

That writes kernels.<platform>.so next to the source, importable as kernels. Each kernel is a genuine numpy.ufunc with full broadcasting:

import numpy as np
import kernels

print(type(kernels.square))                 # <class 'numpy.ufunc'>
print(kernels.square(np.array([1.0, 2.0, 3.0])))   # [1. 4. 9.]

A = np.array([[1.0, 2.0], [3.0, 4.0]])
B = np.array([[1.0, 0.0], [0.0, 1.0]])
print(kernels.dot(A, B))                     # [1. 4.]

--module-name sets the importable name (it must match, since the module init symbol is PyInit_<name>); omit it and the name defaults to the file stem.

Extension modules need the C backend

--ext-module requires the ext_module capability. postc build --ext-module --backend qbe fails with the 'qbe' backend does not build CPython extension modules. Use --backend c (the default) for extension modules; QBE is for the standalone pp_* shared library.

Where to go next

  • CLI reference — every subcommand and flag: build, check, backends, backend-info, plus --prefix install layouts.
  • Architecture — how source lowers through the typed SSA IR that every backend consumes.