Skip to content

ADR-0002 — Debug and release build modes

  • Status: Accepted and shipped. postc build f.py is a debug build; postc build --release f.py elides the checks.
  • Context: the POST Python spec assumes a debug/release distinction that postc did not have. Every build was one undifferentiated thing, and three normative clauses depend on the distinction existing.

The clauses that forced it

Spec Obligation
§4.2 "Bounds checks are required in debug builds and configurable in release builds."
§5.1 assert is "compiled to no-op in release builds".
§4.1.1 "Debug builds must diagnose integer overflow where practical; release builds may use the target platform's native overflow behavior, but compilers must document whether they wrap, trap, or assume no overflow."

The §9.6 conformance table says the same, and until this work postc satisfied none of it: there were no bounds checks anywhere, assertions were live in every build, and integer overflow was never diagnosed.

The answer to §4.1.1's documentation obligation

Release wraps. Debug traps.

Integer arithmetic in a release build wraps at the declared width, which is what the target does natively and what the published benchmark numbers measure. In a debug build the same arithmetic is diagnosed and the program aborts with a message naming what overflowed.

What each mode does

Debug (default) Release (--release)
assert evaluated; a failure reports and aborts not lowered at all, as python -O does
Array subscript per-axis bounds check on the normalized index no check
Integer + - * ** // %, unary -, abs overflow and division-by-zero diagnosed wraps at the declared width
Narrowing cast (Int8(x)), int or float source the defined wrapping conversion, identical to postyp identical
Float→int cast of NaN or ±infinity reports and aborts (postyp raises) returns 0, a documented value
C compiler flags -O2 -fPIC -O2 -fPIC

Negative-index normalisation (a[-1] is the last element) is semantics, not a check, so it survives release. CPython means the last element by it, and a release build has to agree.

The C flags deliberately do not change. -O0 in debug would buy nothing — the checks are in the IR, not in the optimiser — and would risk the C, QBE and LLVM backends diverging on a path where they must stay bit-identical.

Decisions

The mode is a frontend parameter

It decides which IR the frontend emits. No backend sees it or branches on it. Two facts force this, and both were discovered the hard way:

  • QBE's optimisation passes match on the shape of the IR. A check node that a release backend merely skipped would still be a node in the loop body, and QBE's ×4 unroll would die in release too.
  • The benchmark assembles WASM by calling compile_source and the backend directly, never touching build.py. A mode living in the build layer would have left WASM benchmarked in debug against native release.

The consequence, and the acceptance test: release IR is byte-identical to the IR postc emitted before build modes existed. Release is not a new code path, it is the existing one with a name — which is why the published performance numbers were relabelled rather than re-measured.

The default is debug

The shipped default is the one that cannot silently disagree with CPython. That is not free, and it is only affordable because of the next decision.

Bounds checks are hoisted out of loops

A branch in a loop body destroys auto-vectorisation on C and LLVM and kills QBE's unroll. Measured on the square kernel with a naive per-element check: 3.29× (C), 5.76× (LLVM), 3.87× (QBE).

For the shape every kernel is written in — for i in range(len(x)): out[i] = f(x[i]) — the loop already guarantees 0 <= i < len(x). So x[i] needs no check at all, and out[i] needs exactly one, len(out) >= len(x), hoisted into the preheader. The same kernel is then 1.02× / 0.96× / 1.06×, and QBE's unroll survives a debug build.

The hoist is not an optimisation. It is what makes debug-by-default defensible.

Overflow is diagnosed by default, with an opt-out

--no-overflow-checks keeps a debug build's bounds checks and assertions but drops the overflow diagnosis, for code that deliberately relies on modular arithmetic (hashes, checksums, PRNGs).

The justification for the default is that postyp's arithmetic does not wrap: UInt8(255) + UInt8(1) is 256 under CPython and 0 compiled. An overflowing program already disagreed with the interpreted oracle, in every build, silently. Debug reports a divergence that was there all along rather than manufacturing one. Code that wants modular arithmetic is outside the CPython oracle anyway, which is the right place to serve it.

Only user-level arithmetic is checked. Compiler-synthesised arithmetic — a loop counter's increment, an array index's stride chain — stays unchecked, because QBE's strength reduction and unroll match on its exact shape and checking it would kill both passes in both modes.

A failed check aborts

Compiled POST has no exceptions, and try/except is outside the subset. So a failed bounds check, assertion or overflow check reports on stderr and aborts, as C's assert(3) does. "Bounds-checked" here means fail-loud, not recoverable.

What it cost to get right

Three things are worth recording, because each was invisible to the test suite and cost real time.

A check the optimiser is allowed to delete is not a check. The C backend silently missed 64-bit signed multiply overflow while LLVM and QBE caught it. Signed overflow is undefined behaviour in C, so at -O2 the compiler is entitled to assume it cannot happen — and folded the check (a * b) / a != b away to false. The product is now computed through unsigned, where wraparound is defined, so the value the check inspects is a real one.

A correctness fix in the lowering can silently switch off a backend's optimiser. Both the bounds check and the loop-counter fixes did exactly that to QBE, and the benchmark reported it only as "10% slower". The cause was visible only by diffing the emitted IL. test_qbe_unroll_fires.py now asserts on the shape of the emitted IL, which is the only thing that can see it.

A new check asks the IR questions nobody had asked before, and finds old lies. Adding bounds checks exposed a gufunc descriptor that claimed a one-element output slot had zero elements (in all three backends), a QBE CSE pass that left values dangling across block boundaries, and a WASM call lowering that turned every runtime helper into a libm host import. None of those were caused by build modes. All three would have been blamed on them.

What is not done

  • Single-writer assertions (§9.6's fourth row) need the ownership model, which postc does not have.
  • Bounds checks in release are elided outright rather than configurable, which is the spec's word. A two-valued switch is what there is a user for; --release --bounds-checks can be added when somebody asks.
  • An out-of-range narrowing cast of a finite value is not diagnosed at runtime, and deliberately so. postyp defines Int8(200) to be -56 in both halves, and the cast oracle pins that agreement across every float source and int target. A trapping debug cast would abort where the interpreted program returns a defined value — breaking the CPython oracle the whole test strategy rests on, and removing the only way to spell a deliberate truncation. What the spec's normative sentence asks for is a compile-time diagnostic, and that is what shipped: a narrowing return is a PP410.

    An earlier version of this reasoning said the cast "returns a defined value" and stopped there. That was true of int→int and of finite float sources, but false for a non-finite float source: Int64(1e20) compiled to a bare (int64_t)x, which is undefined behaviour, and returned ASLR address bits — a different number every run. Step 6 (notes/step6-float-to-int.md) routed every float→int cast through a wrapping helper so a finite source now genuinely matches postyp in both modes. NaN and ±infinity are the one case with no wrapped value at all (postyp raises), so there debug traps and release returns a documented 0.