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:
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.
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
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
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:
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--prefixinstall layouts. - Architecture — how source lowers through the typed SSA IR that every backend consumes.