Skip to content

perf: make raising and catching exceptions cheaper - #88

Merged
owenthcarey merged 5 commits into
mainfrom
perf/exceptions
Oct 22, 2026
Merged

owenthcarey merged 5 commits into
mainfrom
perf/exceptions

Conversation

@owenthcarey

Copy link
Copy Markdown
Contributor

Makes raising, catching and releasing an exception cheaper.

  • An unwound frame's object is built only when something reads it. A frame an exception leaves now "departs": its shell stands in for the frame object until tb_frame, f_back, the collector or gc.get_referents reads it. A departed shell holds its caller's the way CPython's frame holds f_back, so an unwound chain's locals now die innermost frame first, as in CPython (main freed them outermost first).
  • A raise settles its handler and class checks once. The quiet loop no longer looks up the handler twice per catch, BaseExceptionGroup membership is a memoized family bit instead of two MRO walks, and a failing builtin constructor (int("x")) no longer retries its pure build eight times.
  • Exceptions are built with their slots in place and their args tuples without an intermediate vector; traceback entries are recorded and released without the spine's borrow guards.

The free-order change made the native _ast._build disagree with the Python builder in tests/regrtest/test_native_ast_build_cleanup.py. The test is unchanged; instead, the native builder now hands a node whose fields go through a Python-level setattr to the Python builder. Reviewers may want to look at that trade.

Measurements

macOS, instructions per iteration, main to this branch (CPython 3.14 in parentheses):

Case main branch CPython
try/except of a raised ValueError 3,125 2,554 1,401
KeyError caught 3,411 2,886 1,663
Raise in a callee, caught one frame up 8,179 6,275 2,984
ValueError("x") built and freed 1,864 1,547 958
Failing int("x") 10,111 9,260 8,632

Whole suite, instructions against main: exceptions 0.901, context_managers 0.979, geomean 0.9915; no workload is worse by more than 0.5%.

Validation

  • Bundled regrtest 422/422; VM unit tests (430) pass; clippy with -D warnings is clean.
  • CPython gates test_exceptions, test_traceback, test_exception_group, test_except_star, test_sys_settrace, test_monitoring, test_contextlib and test_raise pass with no unexpected results.

What's left

A raise across a call is still about 2.1 times CPython's cost: the rest is the activation protocol's exceptional return (see RFC 0078 in #86).

An exception leaving an activation that a traceback entry recorded
lazily used to build the frame object at the pop, so a raise caught one
frame up (`try: f() except E: pass`) paid for a frame object nobody
read. The activation now departs instead: its shell, whose slots the
pop already upgrades, stands for the frame object, which is built on
first use (`tb_frame`, `f_back`, the collector's traversal) exactly as
the pop would have left it, off the spine at its last instruction with
its caller as `f_back`.

The departed shell holds its caller's shell the way CPython's frame
object holds `f_back`, so an unwound chain's locals now die inner frame
first, as in CPython (they used to die outer frame first). The native
AST builder leaves nodes whose fields go through a Python `setattr` to
the Python builder, whose frame keeps the node alive above the setter's
in the traceback.
The quiet loop looked a frame's handler up twice per catch (once to
decide, once to jump) and its unwind once more after the caller proved
there was none; `handle_exception_with` takes the answer. A raise of a
builtin class walked the MRO for `BaseExceptionGroup` twice, now one
memoised family bit; an `except` naming a superclass of the raised one
trusts the class's exception flag before walking for `BaseException`;
exception instances are built with their laid-out slots instead of
replacing an empty table; and a departing activation decides in one
pass of its pop, with its departure flag an atomic.
- A failing builtin constructor (`int("x")`) tried its pure build up to
  eight times before the full call: `core_builtin_ctor` retried
  `builtin_ctor_pure` after `leaf_type_call`, which ends in it, and
  `core_native_ctor_kw` tried it again for builtin types that every
  caller also hands to `core_builtin_ctor`. Each site now tries once.
- An exception's `args` tuple is built straight from the arguments, and
  the memoised exception families are read in line.
- A traceback entry releases its chain without borrow guards and no
  longer counts as a nesting container (it tears its chain down
  iteratively, and what it holds nests on its own).
- `PUSH_EXC_INFO`/`POP_EXCEPT` in the core loop update the handled
  exception stack under the raw-access contract of `exc_info_len`.
- A pop borrows the frame spine once, and a dying instance's class is
  read without a guard when nothing else can borrow it.
`BaseException.__init__` copied its arguments into a vector and then
into the `args` tuple, and walked the MRO by name for `StopIteration`
on every call; the construction path's argument seeding collected into
a vector the same way. Both now move the arguments straight into the
tuple (`Object::tuple_from_exact`) and read the memoised exception
families.
The catch path scanned the frame spine for the raising activation's
shell under a borrow guard; only this thread mutates its spine and the
scan runs no code, so it reads it as `exc_info_len` reads the handled
exception stack. The handler also takes the exception instance from
the record instead of cloning it and dropping the record's handle.
@owenthcarey
owenthcarey merged commit ba3053c into main Oct 22, 2026
28 checks passed
@owenthcarey
owenthcarey deleted the perf/exceptions branch October 22, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant