Skip to content

refactor: give a frame's operand stack and locals types of their own (RFC 0078 stage 1) - #93

Merged
owenthcarey merged 35 commits into
mainfrom
perf/activation-stack
Oct 24, 2026
Merged

owenthcarey merged 35 commits into
mainfrom
perf/activation-stack

Conversation

@owenthcarey

@owenthcarey owenthcarey commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #86 (rebased onto perf/cheap-calls, whose unified direct-call binding it adapts); merge #86 first.

RFC 0078 (one activation stack, drafted in #86), stage 1: a frame's operand stack and locals get types of their own (frame_slots.rs), so a later stage can keep both in an activation record on a per-thread stack without touching their users. No behavior changes.

FrameStack (Frame::stack)

The operations frames use: push and pop (with push_fast's in-line room check), the depth, truncate and set_len, the base pointer and capacity the native code addresses, and the top-of-stack moves the call paths make (drain, split_off, extend, remove). The helpers that took a frame's stack as a &mut Vec<Object> take the new type; kw_names_fill_locals, which also binds from staging vectors, takes either through a small Operands trait. The stack pool holds FrameStacks.

FrameLocals (Frame::locals)

Each way the VM reaches a frame's locals is now a named method:

  • shared(): the handle a frame object or a shell holds to read the live values (8 sites), and is_shared() in place of the Rc::strong_count tests that decide whether a return may recycle them.
  • len(), as_mut_ptr() and slots(): the running frame's unguarded reads and the native code's base pointer.
  • storage(): the vector itself, for the binding paths that size, fill and empty it. Its 29 uses are the work stage 2 replaces with record-friendly binding.
  • identity(): the frame's identity while it runs, for the guards that key on it.

The storage itself is still the shared Rc<RefCell<Vec<Object>>> (SharedLocals), which frame objects (locals_mirror) and shells hold unchanged.

Measurements

Instructions against main's quick build: geomean 0.997, workloads from 0.984 to 1.014 (a quick build's layout noise).

Validation

  • Bundled regrtest 422/422; VM unit tests (430) pass; clippy with -D warnings is clean; the workspace's tests build.

Next (stage 2)

Records on a per-thread stack for the activations the quiet loop runs inline (today's pooled InlineActs), linked by prev: their locals and operand stack in the record, frame objects pointing at a live record and taking a copy when it dies.

Record where WeavePy stands against CPython 3.14 (warm time 1.28x,
peak memory 1.71x, startup 0.74x), the callgrind breakdown of the
remaining gap, and the ranked plan for the structural changes.
A call between two compiled functions bound a pooled InlineAct,
pushed it on the core loop's switch, and entered its caller on the
lazy spine's pending list, about 480 instructions in the call helper
alone. It now binds a per-depth LightAct record instead: the callee's
frame and native state, with its code, globals and builtins borrowed
from the callable and no switch or pending-list work at all.

Nothing outside the call sees a light activation. Whatever needs the
core loop's view of it first reifies it, and the light activations
that called it, into the ordinary direct activations the old path
bound: the callee leaving its native code, any helper that switches
the running activation or publishes its caller (ensure_heavy), and a
return that isn't clean. A site whose light calls keep being reified
binds ordinary activations instead.

Positional and keyword calls, operator dunders, property getters and
callees with cells all take the light path; core_bind_kw_direct is
gone. go retires 6% fewer instructions, tomllib_loads 3%, deepcopy
1%; the suite's geomean 0.7%.

New burst-stat lines count light calls and reifications, and a
regression test covers frames, tracebacks, locals, finalizer timing,
the recursion limit and warning stack levels from hot direct calls.
Replace the light activations with one bind for every direct call
between compiled functions (frame_jit::direct_enter): positional and
keyword arguments, defaults, and fresh or closure cells, into a pooled
or site-warm InlineAct pushed on the core loop's switch, as the core
loop binds its own calls.

The light records skipped the switch and pending-list registration,
about 40 instructions a call, but anything that switched activations
had to reify them first, which kept constructors, generator resumes
and builtin lanes off them and took 15 call sites and a frame swap to
get right. They also borrowed the callee's code, which a reassigned
__code__ could free mid-call. With one representation, every shape
runs direct and nothing needs reifying: core_bind_kw_direct,
bind_call_cells and core_bind_plain's DIRECT variant are gone.

Instructions are within 0.3% of before on the call-heavy workloads
(the geomean of go, raytrace, deepcopy, tomllib_loads,
mini_interpreter, sudoku, chaos and exceptions is 0.997). The next
step emits the call in line from compiled code against this record.
Record the design the call-path measurements point to: activations
as records on a per-thread stack, linked to their callers, that the
interpreter, the frame JIT and generators all run, replacing the
pooled InlineAct, the lazy pending list, the per-call native State and
the frameless leaf paths. The RFC gives the measured per-call cost
breakdown, a five-stage plan, and the expected effect.
RFC 0078, stage 1: `Frame::stack` becomes a `FrameStack` with the
operations frames use (push and pop, the depth, the base pointer the
native code addresses, and the top-of-stack moves the call paths make),
so that a later stage can keep it in an activation record without
touching its users. It is still a heap buffer.

The helpers that took a frame's stack as a `&mut Vec<Object>` take the
new type; `kw_names_fill_locals`, which also binds from staging vectors,
takes either through a small `Operands` trait. No behavior changes.
RFC 0078, stage 1: `Frame::locals` becomes a `FrameLocals`, which makes
explicit each way the VM reaches a frame's locals, so that a later stage
can keep them in an activation record:

- `shared()`: the handle a frame object or a shell holds to read the
  live values (eight sites), and `is_shared()` in place of the
  `Rc::strong_count` tests that decide whether a return may recycle
  them;
- `len()`, `as_mut_ptr()` and `slots()`: the running frame's unguarded
  reads and the native code's base pointer;
- `storage()`: the vector itself, for the binding paths that size, fill
  and empty it (29 sites, the work stage 2 replaces);
- `identity()`: the frame's identity while it runs, for the guards that
  key on it.

The storage is still the shared `Rc<RefCell<Vec<Object>>>`
(`SharedLocals`). No behavior changes; instructions are within layout
noise of `main` (geomean 0.997).
@owenthcarey
owenthcarey force-pushed the perf/activation-stack branch from 613528b to 6ec6938 Compare October 22, 2026 03:26
@owenthcarey
owenthcarey changed the base branch from main to perf/cheap-calls October 22, 2026 03:26
A positional call of a function with `*args`, `**kwargs`, or keyword-only
parameters (a decorator's `def wrapper(*args, **kwargs)`, `def f(a, *,
key=None)`) took the general inline call every time: `core_call_plain`
and the frame JIT's direct call declined extended parameters and
closures, and `core_call_cached` re-derived the shape on each call.

The cached plain call and the direct call now bind these shapes
themselves (`fill_extended_raw`: the surplus as the `*args` tuple, the
compiled keyword-only defaults, an empty `**kwargs` dictionary), along
with the callee's cells, so `core_call_cached` goes away. A direct
site carries the extended-parameter flag beside its cells flag, so the
common shape pays nothing for the check.

Frameless leaves with keyword-only parameters take their defaults at
frame JIT leaf sites and fused method sites too, and a shape mismatch
no longer reaches `pure_leaf_call`, whose bail counter could otherwise
retire a leaf for good.

Per call (instructions retired, quick build): the forwarding wrapper
3368 -> ~2460, `fva(1, 2, 3)` 1817 -> ~1245, `fkw(i)` 835 -> ~760.
Suite geomean 0.999 (closures_decorators -1.3%, context_managers -1.8%).
Keyword calls that the call site's permutation can't bind (a callee
with `*args`, a closure, a native caller) now share one by-name binder,
`lean_bind_keywords`, which takes its positionals as slices: the
`*args` surplus builds its tuple in place, the first keyword is tried at
the parameter after the positionals, and keyword-only defaults are
looked up only for the parameters left unbound. `call_python_kw_names`
binds through it instead of computing a permutation per call, and a
tier-2 dynamic keyword call of a plain function now goes there too
instead of converting each keyword name into a `String` for the generic
binder.

`f(*args, **kwargs)` of a frameless leaf (the forwarding wrapper's
inner call) evaluates without allocating: the operands leave the stack
as a fixed array instead of a collected vector, the function and code
are borrowed, and keyword-only parameters take their defaults.

A static or class method read through an instance is remembered in the
method-load site's slot (`MethodSlotFn::ClassFn` beside `Static`), so the
frame JIT's method load serves it and the call stays native.

Per call (instructions retired): the forwarding wrapper ~2460 -> ~2270,
`o.sm(i)` 1280 -> ~790, `o.cm(i)` 1361 -> ~960, `fvk(1, 2, x=3)` in a
tier-2 loop 5526 -> ~4530. Suite geomean 0.998 against main
(closures_decorators -2.0%, context_managers -2.3%).
A `functools.partial` with more than one stored positional, or with
stored keywords, was called through its native `__call__` from the slow
step: the builtin lane's leaf probes, `dispatch_call`, and a nested
call of the function. The `CALL` arms now send it down their Python
path, where `core_partial_call` binds the stored positionals, the
call's, and the stored keywords by name (as CPython's
`partial_vectorcall` does) and runs the function as an inline
activation, or a warm pure leaf in place. Anything that binder declines,
errors included, still takes the native `__call__`.

A pure leaf's keyword binding now has a pointer form,
`leaf_bind_keywords`: each parameter points into the positionals, the
keyword values, or the function's defaults, with no reference taken.
`f(*args, **kwargs)` of a pure leaf with a non-empty mapping (the
forwarding wrapper called with keywords) and a partial over a pure leaf
evaluate through it in place. `core_call_ex` classifies its callee once
instead of probing the generator and class paths for every function.

`SlotStorage::leading_interned` reads a `__slots__` layout's leading
values by key identity (its keys are interned), which the partial's
field reads take on every call.

Per call (instructions retired): `partial(f3, 1, 2)(3)` 4511 -> ~1510
(CPython 914), `partial(fkw, c=5)(i)` 7222 -> ~1910 (CPython 2134), the
forwarding wrapper called with a keyword 4187 -> ~3400. Suite geomean
0.994 against main (closures_decorators -5.8%, context_managers -2.8%,
xml_etree -1.9%).
Tier 2's dynamic call helper treated a keyword call of a Python
function as free: the comment held that tier 1 paid the same generic
binder. Tier 1 now binds those calls inline (by the site's permutation,
by name, or as a frameless leaf), so a native loop that makes one per
iteration is a thin driver around interpreter round trips, each paying
the activation shell, the guard revalidation and the pin traffic. Such
calls now count toward the call-density budget that retires the loop to
tier 1, as positional generic calls already do. Keyword calls of native
callees stay uncharged.

Per call (instructions retired, microops loop): `fvk(1, 2, x=3)` 4530 ->
~2630 (it ran 5526 on main), `fkw(i, c=4)` 1226 -> ~905. The suite is
unchanged (geomean 0.9995 against the previous commit, within noise).
A `CALL_KW` site's `CallPyKwNames` permutation (the keyword values'
slots, computed once per site) refused callees with `*args` and
closures, so `wrapper(x=1)` against a decorator's `def wrapper(*args,
**kwargs)` and `f(1, 2, key=3)` against `def f(*args, key=None)` bound by
name on every call and switched through the general inline call.

The permutation now covers surplus positionals (they go to the `*args`
tuple, which `kw_names_fill_locals` builds) and closures whose code has
no cell variables of its own (their cells are the function's, bound as
the lean cells), and the frame JIT's direct keyword call takes both,
so these calls bind by moving their operands and run as direct calls.
The full handler's keyword fast path and tier 2's site binding now run a
closure's frame with its cells (`call_python_bound`), which they could
not before because no closure ever got a permutation.

Per call (instructions retired): `fvk(1, 2, x=3)` ~2630 -> ~2270 (5526
on main), the forwarding wrapper called with a keyword ~3360 -> ~3130
(4187 on main). Suite unchanged (geomean 1.0008 against the previous
commit, within noise; 0.994 against main).
A native caller's call of a Python function (an `lru_cache` miss, a
class's `__init__` from `instantiate`, a `key=` function that isn't a
frameless leaf) copied its arguments into a fresh vector for
`call_python_owned`, which only resized it for the exact positional
shape. `call_python` now binds that shape itself into a pooled vector,
so the call allocates nothing for its locals.

A lean `__init__` with `*args` (`contextlib.suppress.__init__(self,
*exceptions)`) built its surplus tuple from a collected vector; it now
builds the tuple from the remaining arguments in place.

Per call (instructions retired): a recursive `lru_cache(maxsize=0)`
call pair 5604 -> ~5440, an `lru_cache` miss of a looping function
4864 -> ~4590. Suite geomean 0.998 against the previous commit
(context_managers -0.7%), 0.992 against main.
This reverts eedc1bd ("perf: leave keyword-call-dense loops to tier
1"). Charging tier 2's keyword calls against the call-density budget
retired whole native loops whose other calls tier 2 runs natively: the
`call_overhead` bench fixture (positional, default, keyword and method
calls in one loop) fell back to tier 1 and ran 2.2 to 2.6 times slower
than main (3390 -> 8200 instructions per iteration). A keyword call
costs tier 1 less only when it is nearly all the loop does, so the
budget isn't the place to judge it.
# Conflicts:
#	crates/weavepy-vm/src/gen_fast.rs
#	crates/weavepy-vm/src/lib.rs
@owenthcarey
owenthcarey changed the base branch from perf/cheap-calls to main October 23, 2026 19:55
# Conflicts:
#	crates/weavepy-vm/src/frame_jit.rs
#	crates/weavepy-vm/src/lib.rs
# Conflicts:
#	crates/weavepy-vm/src/frame_jit.rs
#	crates/weavepy-vm/src/lib.rs
…vation-stack

# Conflicts:
#	crates/weavepy-vm/src/lib.rs
@owenthcarey
owenthcarey merged commit 6bfcc24 into main Oct 24, 2026
28 checks passed
@owenthcarey
owenthcarey deleted the perf/activation-stack branch October 24, 2026 04:13
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