Error Handling¶
PyPTO's error handling framework provides structured exceptions with C++ stack traces, assertion macros with IR source location tracking, and a diagnostic system for verification errors.
Overview¶
| Component | Header | Purpose |
|---|---|---|
| Exception hierarchy | include/pypto/core/error.h |
Typed exceptions (ValueError, InternalError, …) with automatic stack trace capture |
| Assertion macros | include/pypto/core/logging.h |
CHECK / CHECK_SPAN, INTERNAL_CHECK_SPAN, UNREACHABLE / UNREACHABLE_SPAN, etc. |
| Diagnostic system | include/pypto/core/error.h |
Diagnostic / VerificationError for verification passes |
| Span | include/pypto/ir/span.h |
IR source location attached to diagnostics and internal checks |
Exception Hierarchy¶
All exceptions inherit from Error, which captures the C++ stack trace at construction time via libbacktrace.
std::runtime_error
└── Error (base: auto stack trace capture)
├── ValueError (→ Python ValueError)
├── TypeError (→ Python TypeError)
├── RuntimeError (→ Python RuntimeError)
├── NotImplementedError
├── IndexError
├── AssertionError
├── InternalError (→ Python RuntimeError — internal bugs)
└── VerificationError (carries vector<Diagnostic>)
Error::GetFullMessage() returns the error message plus a formatted C++ stack trace.
When the stack trace is shown¶
Every Error captures a trace at construction, but the exception translator
(python/bindings/modules/error.cpp) only attaches it to the Python message when it helps:
| Exception | Raised by | Trace in the Python message |
|---|---|---|
InternalError, AssertionError |
INTERNAL_CHECK family |
Always — a failed invariant is a PyPTO bug, and the frames are the primary artefact |
ValueError, TypeError, RuntimeError, IndexError, NotImplementedError, VerificationError |
CHECK family, user input |
Only under PTO_BACKTRACE=1 |
A user error already carries its own DSL source snippet; the C++ frames name PyPTO internals the
caller cannot act on, and printing them in between pushes the snippet further down. That includes
NotImplementedError: an unlowered feature is a documented limitation surfaced to the user, the
same category CHECK covers, not a failed invariant. PTO_BACKTRACE=1 is the same switch the DSL
diagnostics advertise, so one variable turns on both backtraces.
Only the exact value 1 enables C++ traces; any other value leaves them off. The DSL parser is
stricter — it rejects anything other than 0 or 1 — so stick to those two values.
Backtrace::FormatStackTrace drops frames belonging to infrastructure rather than to the call
path — libbacktrace, nanobind, libc, the C++ standard library, and the error.h / logging.h
throw sites (kFileNameFilter in src/core/backtrace.cpp).
Platform support for stack traces¶
3rdparty/libbacktrace tracks upstream ianlancetaylor/libbacktrace.
Upstream's Mach-O reader accepts only MH_EXECUTE, MH_DYLIB and MH_DSYM, but a CPython
extension module is an MH_BUNDLE — so on macOS symbolization fails and GetFullMessage()
falls back to No stack trace available. No build mode changes this; it is a filetype gate, not
missing debug info. Linux (ELF) is unaffected, but still needs debug info: Debug or
RelWithDebInfo (the default) pass -g and produce full traces, while a plain Release build is
-O2 -DNDEBUG with no -g, so frames carry no source location and the same fallback appears.
Backtrace::ErrorCallback reports each distinct (message, errno) pair only once. Without that,
macOS would emit one no debug info in Mach-O executable line per stack frame of every captured
trace, since the dyld init path succeeds overall and installs macho_nodebug as the fileline
handler.
Assertion Macros¶
User-facing checks — CHECK / CHECK_SPAN¶
Throw ValueError when a user-visible contract is violated. CHECK_SPAN attaches the IR source location, just like INTERNAL_CHECK_SPAN — prefer it whenever a Span is reachable so the user can see which DSL line tripped the check:
CHECK(args.size() == 2) << "op requires exactly 2 arguments, got " << args.size();
CHECK_SPAN(shape.size() == 2, span) << "tensor.matmul: only 2D inputs are supported";
The span argument follows the same safety rule as INTERNAL_CHECK_SPAN: it is evaluated only on failure, but unconditionally evaluated there. The span source must therefore be safe to dereference at the failure point (typically a local Span variable or a sibling IR node known non-null).
Internal invariant checks — INTERNAL_CHECK_SPAN¶
Throw InternalError when an internal invariant is violated. Always attach the IR node's Span so the error message includes the user's source location:
INTERNAL_CHECK_SPAN(op->var_, op->span_) << "AssignStmt has null var";
INTERNAL_CHECK_SPAN(new_value, op->span_) << "AssignStmt value mutated to null";
When the check fails, the error message includes both the IR source location and the C++ location:
AssignStmt has null var [user_model.py:42:1]
Check failed: op->var_ at src/ir/transforms/mutator.cpp:301
There is also INTERNAL_UNREACHABLE_SPAN for code paths that should never be reached:
Variants without span¶
INTERNAL_CHECK and INTERNAL_UNREACHABLE do not carry IR source location. They are appropriate when no Span is available (e.g., in non-IR contexts like arithmetic utilities or registry lookups). When an IR node is being processed and op->span_ is accessible, prefer the _SPAN variants.
Unreachable code paths — UNREACHABLE / UNREACHABLE_SPAN¶
Throw ValueError for code paths that should be unreachable from a user perspective. Prefer UNREACHABLE_SPAN when an IR span is available:
UNREACHABLE << "Unsupported data type: " << dtype;
UNREACHABLE_SPAN(node->span_) << "Unsupported data type: " << dtype;
Macro Reference¶
| Macro | Exception Type | Span | Status |
|---|---|---|---|
CHECK(expr) |
ValueError |
No | Active |
CHECK_SPAN(expr, span) |
ValueError |
Yes | Preferred when span available |
UNREACHABLE |
ValueError |
No | Active |
UNREACHABLE_SPAN(span) |
ValueError |
Yes | Preferred when span available |
INTERNAL_CHECK_SPAN(expr, span) |
InternalError |
Yes | Preferred |
INTERNAL_UNREACHABLE_SPAN(span) |
InternalError |
Yes | Preferred |
INTERNAL_CHECK(expr) |
InternalError |
No | Active (use _SPAN when span available) |
INTERNAL_UNREACHABLE |
InternalError |
No | Active (use _SPAN when span available) |
Diagnostic System¶
The diagnostic system is used by IR verification passes to collect multiple issues before reporting.
Each Diagnostic carries:
| Field | Type | Purpose |
|---|---|---|
severity |
DiagnosticSeverity |
Error or Warning |
rule_name |
string |
Which verification rule detected the issue |
error_code |
int |
Numeric error identifier |
message |
string |
Human-readable description |
span |
Span |
IR source location |
VerificationError is thrown when verification fails, carrying all collected diagnostics.
Span and Source Location¶
Every IR node inherits a span_ field from IRNode (see IR Overview). This field tracks the user's source location (filename, line, column) and is used in two error paths:
- Verification diagnostics — verifier passes record
op->span_intoDiagnosticobjects - Assertion checks —
CHECK_SPAN/UNREACHABLE_SPAN/INTERNAL_CHECK_SPAN/INTERNAL_UNREACHABLE_SPANembedspan.to_string()into the failure message
When a Span is valid, the error output appends [file:line:col] to the message. When Span::unknown() is used, no source location is shown.
Python API¶
import pypto
# User-facing check (raises ValueError)
pypto.check(condition, "error message")
# Internal invariant check with span (raises RuntimeError)
pypto.internal_check_span(condition, "error message", span)
# Raise InternalError with span (for testing or unconditional error paths)
pypto.raise_internal_error_with_span("error message", span)
# Internal invariant check without span
pypto.internal_check(condition, "error message")
Migration Guide¶
When writing or touching code in IR transforms, passes, or codegen:
- Identify the current IR node being processed (
op,stmt,expr, etc.) - Replace
INTERNAL_CHECK(expr)withINTERNAL_CHECK_SPAN(expr, op->span_)(andINTERNAL_UNREACHABLEwithINTERNAL_UNREACHABLE_SPAN) - Likewise replace user-facing
CHECK(expr)withCHECK_SPAN(expr, op->span_)(andUNREACHABLEwithUNREACHABLE_SPAN) when a span is reachable - If a
Spanis available as a function parameter (e.g., inReconstruct*helpers or op-conversion lambdas), use that directly
// Before:
INTERNAL_CHECK(op->body_) << "ForStmt has null body";
CHECK(args.size() == 2) << "tensor.matmul requires 2 args";
// After (preferred when span is available):
INTERNAL_CHECK_SPAN(op->body_, op->span_) << "ForStmt has null body";
CHECK_SPAN(args.size() == 2, span) << "tensor.matmul requires 2 args";
Inside passes: CHECK vs INTERNAL_CHECK¶
Passes operate on IR that has already been verified by earlier passes. A failed invariant inside a pass therefore almost always indicates a compiler bug, not a user error — use INTERNAL_CHECK_SPAN / INTERNAL_UNREACHABLE_SPAN. Reserve CHECK_SPAN for genuine user-facing limitations that the user can work around (e.g. "4D scatter_update is not yet lowered — use 2D"). If you're unsure, ask: would the message read "this is a PyPTO bug, please report" or "please change your code"?
Related¶
- IR Overview — Source Location Tracking
- IR Verifier — Diagnostic System
include/pypto/core/error.h— Exception classes andDiagnosticinclude/pypto/core/logging.h— Assertion macros andFatalLoggerinclude/pypto/ir/span.h—Spanclass