Current PyPy: runtime linked list of FrameBlock objects (ExceptBlock, FinallyBlock, SysExcInfoRestorer) anchored at frame.lastblock. The compiler emits SETUP_FINALLY/SETUP_EXCEPT/POP_BLOCK to manage this list. On exception, unrollstack() walks the list to find the handler.
CPython 3.11: no block stack at all. The exception table co_exceptiontable maps bytecode offset ranges to (handler_offset, stack_depth, lasti_flag). Only consulted when an exception actually fires — zero overhead on the happy path.
Adopting CPython's model might improve JIT output. Three reasons:
SETUP_FINALLY/POP_BLOCKcurrently generate virtualExceptBlock/FinallyBlockallocations that the JIT eliminates. With the table model those allocations disappear entirely — nothing to eliminate.lastblockis currently inPyFrame._virtualizable_, requiring the JIT to track it at every escape point. It can be removed, simplifying the virtualizable save/restore machinery.- Exception dispatch (
unrollstack) happens outside the traced fast path anyway (it's the guard-failure/blackhole path). Replacing it with a table scan doesn't touch JIT-compiled code at all.
| Phase | What | Risk | Status |
|---|---|---|---|
| 0 | Add co_exceptiontable to PyCode, encoder in assemble.py |
Low | Done |
| 1 | PUSH_EXC_INFO, CHECK_EXC_MATCH opcodes |
Medium | Done |
| 2 | handle_operation_error → table lookup, update RERAISE/POP_EXCEPT/WITH_EXCEPT_START |
High | Done (translates) |
| 3 | Compiler: stop emitting SETUP_FINALLY/POP_BLOCK, emit table entries |
High | Done (translates) |
| 4 | JIT: remove lastblock from _virtualizable_, update trace tests |
Low | Not started |
| 5 | fset_f_lineno: replace markblocks/compatible_block_stack with table-based validation |
Medium | Not started |
| 6 | Remove dead code (FinallyBlock, ExceptBlock, SETUP_FINALLY, etc.) |
Low | Not started |
Critical constraint: Phases 2 and 3 must be developed in lockstep — compiler output must exactly match the new interpreter expectations. Cannot be done incrementally without a feature flag to run both models in parallel.
Highest-risk point: handle_operation_error rewrite. It has subtle interactions with generators, coroutines, sys.exc_info() save/restore, the hidden_operationerr mechanism, and JIT blackhole behavior.
Estimated complexity: Multi-month project. The compiler changes alone (all try/except/finally/with/except* patterns in codegen.py) are the bulk of the work.
Each entry is encoded as a sequence of variable-length integers (CPython-compatible):
(start, length, target, depth, lasti) where:
start: byte offset of guarded range startlength: length of guarded rangetarget: byte offset of handler blockdepth: value stack depth at handler entry (before the handler pushes its own values)lasti: bool — whether to preservelast_instrat handler entry (used byRERAISE 1andwith)
Entries are stored in bytecode order (non-decreasing start). lookup_exceptiontable finds the innermost covering entry for a given offset.
Returns a sentinel tuple (r_uint(0), -1, False) when no handler is found (depth == -1 signals not-found). Callers check depth >= 0.
Marked @jit.dont_look_inside because:
- It now contains a
while self.blockstack_non_empty():loop (to pop legacySysExcInfoRestorer/FinallyBlockblocks before jumping to a table-found handler) selfis a virtualizablePyFrame, so RPython creates an__AccessDirectspecialization- A loopy
access_directlyfunction that the JIT can't look inside triggers a hardValueErrorin the JIT codewriter policy handle_operation_erroris only ever called fromexceptblocks (not from JIT-traced hot paths), sodont_look_insidehas no JIT performance cost
New flow: after recording the traceback, try the exception table first. If an entry is found, pop legacy blocks, adjust the value stack to depth, push the normalized exception, and return the target offset. If no entry, fall back to unrollstack() (old block-stack path, still needed for with blocks during the Phase 3→6 transition).
Dual-mode:
- Old path (TOS is
SApplicationException): block-stack reraise, unchanged. - New path (TOS is a
BaseExceptioninstance): reconstruct anOperationErrorand callhandle_operation_error(attach_tb=False).
Dual-mode:
- Old path (block stack top is
SysExcInfoRestorer): pop block and restoresys.exc_info. - New path: pop
w_prev_excfrom value stack, callec.set_sys_exc_info3(w_prev_exc).
Dual-mode (detected via isinstance(TOS, SApplicationException)):
- Old path:
[..., __exit__, unroller]on stack — extract exception fromunroller.operr. - New path:
[..., __exit__, prev_exc, exc]on stack —excis TOS,__exit__is at offset 2.
| Error | Cause | Fix |
|---|---|---|
NoSuchAttrError: 'operr' on W_Root in WITH_EXCEPT_START |
popvalue() returns W_Root; annotator didn't narrow it |
Reuse w_top (already narrowed by isinstance check) |
UnionError: SomeTuple vs SomeNone in lookup_exceptiontable |
Initial best = None vs tuple return |
Use sentinel tuple (r_uint(0), -1, False); caller checks depth >= 0 |
AnnotatorError: sorted is not RPython in _build_exceptiontable |
sorted(..., key=lambda) not supported |
Iterate directly (entries are naturally in bytecode order) |
ValueError: access_directly on function we don't see handle_operation_error__AccessDirect_None |
New loop in handle_operation_error body + virtualizable self access |
Add @jit.dont_look_inside to handle_operation_error |
<table: body → L_exc, depth=D>
<body>
JUMP_FORWARD L_else
L_exc: PUSH_EXC_INFO
LOAD T / CHECK_EXC_MATCH / POP_JUMP_IF_FALSE L_reraise
POP_TOP
<handler>
POP_EXCEPT / JUMP_FORWARD L_end
L_reraise: RERAISE 1
L_else: <orelse>
L_end:
<table: body → L_exc, depth=D>
<body>
JUMP_FORWARD L_else
L_exc: PUSH_EXC_INFO
LOAD T / CHECK_EXC_MATCH / POP_JUMP_IF_FALSE L_reraise
STORE_NAME name # [prev_exc]
<table: hbody → L_cleanup, depth=D+1>
<handler body>
LOAD_CONST None / STORE_NAME name / DELETE_NAME name
POP_EXCEPT / JUMP_FORWARD L_end
L_cleanup: PUSH_EXC_INFO
LOAD_CONST None / STORE/DELETE name
RERAISE 1
L_reraise: RERAISE 1
L_else: <orelse>
L_end:
<table: body → L_final_exc, depth=D>
<body>
<finally>
JUMP_FORWARD L_end
L_final_exc: PUSH_EXC_INFO
<finally>
RERAISE 1
L_end:
<__enter__ call, push result>
<table: body → L_exc, depth=D>
<body>
POP_BLOCK / <__exit__(None,None,None)> / POP_TOP
JUMP_FORWARD L_end
L_exc: PUSH_EXC_INFO
WITH_EXCEPT_START
POP_JUMP_IF_TRUE L_suppress
RERAISE 1
L_suppress: POP_TOP / POP_EXCEPT
L_end:
| Test | Status |
|---|---|
| test_with | PASS (50/50) |
| test_trace | PASS |
| test_contextlib | PASS |
| test_generators | 2 failures (test_except_throw_bad_exception, coroutine doctest) |
| test_exceptions | 4 failures (not yet triaged) |
| test_coroutines | not yet run |
| test_sys_settrace | not yet run (15 failures expected; blocked on Phase 5) |
Remove lastblock from PyFrame._virtualizable_ in pypy/module/pypyjit/interp_jit.py. Update JIT trace tests. Low risk, purely additive.
The markblocks/compatible_block_stack machinery in pycode.py validates that f_lineno assignments land on valid bytecode boundaries. It currently requires a block stack that no longer exists. Replace with exception-table-based validation. This unblocks the 15 test_sys_settrace failures.
Once all tests pass: remove ExceptBlock, FinallyBlock, SysExcInfoRestorer, SETUP_FINALLY, SETUP_EXCEPT, POP_BLOCK, and the dual-mode branches in RERAISE/POP_EXCEPT/WITH_EXCEPT_START/handle_operation_error.
Triage the test_generators and test_exceptions failures to determine whether they are regressions introduced by Phases 2+3 or pre-existing issues.