Split InterpreterFrame hot/cold fields into FrameColdData - #8433
Split InterpreterFrame hot/cold fields into FrameColdData#8433youknowone wants to merge 11 commits into
Conversation
Extract the frame entry (recursion check, TLS link, exception save) and exit (materialization sync, TLS restore, GC tracking) logic from with_iframe into standalone enter_iframe/exit_iframe methods. with_iframe now calls them, with no behavioral change. This prepares for the trampoline loop where enter/exit are called individually rather than wrapped around a closure. Assisted-by: Claude
Add InterpreterFrame::new_on_datastack() that bump-allocates both the InterpreterFrame struct and its LocalsPlus data array in a single datastack push, eliminating one allocation per function call. Update datastack_frame_size_bytes_for_code() to include InterpreterFrame size. Convert invoke_prepared_exact_args() and the invoke() fast path to use the combined allocation. Add release_datastack_frame() method on InterpreterFrame that drops all localsplus values, runs field destructors (trace, temporary_refs, retained_back, etc.), and returns the datastack base pointer for pop. Assisted-by: Claude
Add ExecutionResult::TailCall variant and a trampoline in run_frame_fast that flattens Python-to-Python calls into a single Rust stack frame instead of recursing through the eval loop. CallPyExactArgs now prepares the callee frame on the datastack and returns TailCall when tailcall_enabled is set (run_iframe path only). The trampoline dispatches via a state machine (EnterCallee / ReturnValue / Unwind) in a single loop, avoiding mutual recursion between helper functions that would exhaust the C stack. Exception propagation through suspended frames uses trampoline_handle_exception which adds traceback entries and calls unwind_blocks on each caller. Assisted-by: Claude
…, add bound method TailCall - Add enter_iframe_unchecked for trampoline callee entry (recursion already checked by specialization_call_recursion_guard) - Move callable ownership from per-frame temporary_refs mutex to trampoline-local SuspendedFrame.owned_refs via VM side channel - Add TailCall support for CallBoundMethodExactArgs - Move args directly from caller stack to callee fastlocals - Read materialized pointer once in exit_iframe Incremental call overhead: ~55 ns -> ~35 ns Assisted-by: Claude
The VM is per-thread so RefCell's runtime borrow checking is unnecessary overhead. Replace with UnsafeCell for direct access. Assisted-by: Claude
Assisted-by: Claude
Use NonNull + Option instead of raw *mut T with manual null checks. The compiler enforces non-null via the type system, and Option<NonNull> has the same size as a raw pointer thanks to niche optimization. Also extract take_pending_tailcall helper to deduplicate the pattern. Assisted-by: Claude
Rename SendNonNull to PendingFrame and make it fully private: the struct, its field, and the pending_tailcall_frame Cell are all non-pub. External code accesses the side channel only through set_pending_tailcall (pub(crate)) and take_pending_tailcall (private). This ensures the unsafe Send+Sync impl cannot be reused elsewhere without justifying a new safety argument. Assisted-by: Claude
enter_iframe_unchecked was skipping C-stack checks under the assumption that the trampoline stays in one Rust stack frame. But each run_iframe call still consumes Rust stack, so deep Python recursion through the trampoline can exhaust the C stack (observed as STATUS_STACK_OVERFLOW on Windows CI). Keep the C-stack check (every 8th call) while still skipping the Python recursion depth check (already done by specialization_call_recursion_guard). Assisted-by: Claude
- Fix double-free: mark entry frame with is_entry flag in SuspendedFrame so the trampoline skips its datastack release (the caller owns that cleanup) - Clear iframe.previous in exit_iframe before unlinking the chain, matching with_frame and resume_gen_frame behavior - Add scopeguard in with_iframe for panic safety - Use saturating_sub(1) for lasti in trampoline_handle_exception - Extract datastack_iframe_localsplus_offset helper to avoid duplicated alignment computation Assisted-by: Claude
Move 10 rarely-used fields (trace, trace_lines, trace_opcodes, temporary_refs, f_locals_hidden_overlay, f_extra_locals, escaped, retained_back, pending_stack_pops, pending_unwind_from_stack) from InterpreterFrame into a lazily-allocated FrameColdData struct. InterpreterFrame now carries a single UnsafeCell<Option<Box<FrameColdData>>> (8 bytes) instead of ~200+ bytes of cold fields. The cold() accessor allocates on first access; frames that never trigger tracing or debugging pay no allocation cost. GC traversal skips cold data when it has not been allocated. Assisted-by: Claude
📝 WalkthroughWalkthroughThe VM stores infrequently used frame state in lazy cold data, co-allocates frames with locals on the datastack, and executes eligible Python calls through an iterative tail-call trampoline. The PR also updates related frame accessors and removes the Claude session-start hook. ChangesVM frame execution
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR reduces hot-path InterpreterFrame size and initialization cost by moving tracing/debug/inspection state into a lazily allocated FrameColdData, and updates the VM/frame execution paths to work with the new hot/cold split (including TailCall/trampoline plumbing and datastack frame lifecycle updates).
Changes:
- Introduces
FrameColdDataand migrates rarely-usedInterpreterFramefields behind a lazycold()accessor; updates GC traversal to skip cold data when unallocated. - Updates frame-related call sites (
f_trace*,retained_back,temporary_refs, stack unwind bookkeeping) to route throughcold(). - Extends the TailCall/trampoline and datastack frame lifecycle code to cooperate with the new frame layout and cleanup semantics.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| crates/vm/src/frame.rs | Adds FrameColdData, cold() accessor, datastack co-allocation helpers, TailCall result + trampoline exception helper; updates traversal and field accesses. |
| crates/vm/src/vm/mod.rs | Adds TailCall side channels on VirtualMachine, trampoline loop, and refactors with_iframe into enter_iframe/exit_iframe. |
| crates/vm/src/vm/thread.rs | Initializes new TailCall side-channel fields when creating a threaded VM. |
| crates/vm/src/builtins/frame.rs | Routes Python frame attribute accessors/mutators and clear paths through cold() fields. |
| crates/vm/src/protocol/callable.rs | Updates opcode tracing and per-frame trace clearing to use cold() fields. |
| crates/vm/src/object/ext.rs | Routes temporary_refs bookkeeping through cold(). |
| crates/vm/src/stdlib/_thread.rs | Updates cross-thread frame chain materialization to use cold().retained_back. |
| crates/vm/src/builtins/type.rs | Routes temporary keep-alives through cold().temporary_refs. |
| crates/vm/src/builtins/function.rs | Switches fast-path call frame creation to InterpreterFrame::new_on_datastack and updates datastack sizing helper. |
| crates/vm/src/coroutine.rs | Marks TailCall as unreachable for generator/coroutine execution paths. |
| .claude/settings.json | Removes the Claude session-start hook configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Access the lazily-allocated cold data, allocating on first use. | ||
| #[inline] | ||
| pub(crate) fn cold(&self) -> &FrameColdData { | ||
| let ptr = self.cold.get(); | ||
| unsafe { (*ptr).get_or_insert_with(|| Box::new(FrameColdData::default())) } | ||
| } |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (1)
crates/vm/src/vm/mod.rs (1)
2158-2184: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winRemove the duplicated C-stack check in
enter_iframe.
enter_iframerunsdepth & 7 == 0 && self.check_c_stack_overflow()at Line 2165, then callsenter_iframe_unchecked, which runs the identical check at Line 2182. The check executes twice on every non-trampoline frame entry.enter_iframe_uncheckedalready documents that it keeps the C-stack check, soenter_iframeonly needscheck_recursive_call.♻️ Proposed simplification
) -> PyResult<IframeEntryState> { self.check_recursive_call("")?; - - let depth = self.recursion_depth.get(); - if depth & 7 == 0 && self.check_c_stack_overflow() { - return Err(self.new_recursion_error(String::new())); - } - self.enter_iframe_unchecked(iframe) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/vm/src/vm/mod.rs` around lines 2158 - 2184, Remove the duplicated depth-gated check_c_stack_overflow block from enter_iframe, leaving check_recursive_call and the call to enter_iframe_unchecked intact. Keep the existing C-stack validation exclusively in enter_iframe_unchecked.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/vm/src/frame.rs`:
- Around line 2321-2350: Extract the duplicated ExecutingFrame initialization
into a shared constructor such as ExecutingFrame::from_iframe, accepting iframe,
tailcall_enabled, resolve_builtins_dict, and vm. Replace the construction blocks
in Py<FrameObject>::with_exec, Py<FrameObject>::yield_from_target,
trampoline_handle_exception, and run_iframe, passing each site’s differing
builtins_dict behavior and tailcall_enabled value while preserving all existing
field assignments.
- Around line 10635-10668: The prepared callee frame is not cleaned up if
argument transfer unwinds before publication. In the frame-preparation flow
around InterpreterFrame::new_on_datastack and vm.set_pending_tailcall, add an
unwind guard that releases the callee data-stack frame and restores the caller
stack base, then disarm it only after set_pending_tailcall succeeds; apply the
same protection to tailcall_prepare_bound_method_frame.
- Around line 10604-10726: Merge tailcall_prepare_frame and
tailcall_prepare_bound_method_frame by extracting a shared helper that accepts
the resolved PyFunction, stack argument count, and optional self value. Move the
shared locals creation, datastack frame construction, fastlocals population,
stack cleanup, and pending-tailcall publication into that helper. Update both
existing methods to resolve their function and self values, then delegate while
preserving ownership transfer for the callable and bound-function references.
- Around line 2308-2369: Update trampoline_handle_exception to emit
monitoring::fire_py_unwind when EVENT_PY_UNWIND is enabled, using the computed
call-site offset idx as u32 * 2, immediately before invoking unwind_blocks.
Preserve the existing traceback setup and unwind result propagation.
- Around line 871-881: Make the lazy initialization performed by
FrameObject::cold race-safe instead of mutating
UnsafeCell<Option<Box<FrameColdData>>> through a shared reference. Either
enforce owner-thread/STW-only access before allocation across cold() and its
tracing/accessor callers, or replace the storage and publication path with
atomic initialization that guarantees one shared FrameColdData allocation is
retained and visible to concurrent readers.
- Around line 1455-1458: Update the release or compatibility notes to record the
public ExecutionResult::TailCall variant addition and identify it as a breaking
API change requiring downstream exhaustive matches to handle the new variant.
In `@crates/vm/src/vm/mod.rs`:
- Around line 1424-1442: The fast-call execution paths need unwind-safe cleanup.
In crates/vm/src/vm/mod.rs lines 1424-1442, guard entry_state with scopeguard
around crate::frame::run_iframe, including each run_iframe call and suspended
frame_stack handling in run_frame_fast_trampoline. In
crates/vm/src/builtins/function.rs lines 632-650, guard the iframe so
release_datastack_frame and vm.datastack_pop(base) execute if vm.run_frame_fast
unwinds; apply the same protection around vm.run_frame_fast(iframe) in
invoke_prepared_exact_args at lines 803-827.
- Around line 2387-2389: Reformat the condition in the traced-frame return path
with default rustfmt, and reorder its operands to check
self.is_none(&self.profile_func.borrow()) before accessing
frame.iframe().cold().trace, avoiding unnecessary cold-block allocation for
profile-only frames while preserving the existing logic.
- Around line 2249-2253: Update the retained_back assignment in exit_iframe to
preserve an existing caller reference: lock retained_back, assign the
materialize_chain result only when the guard is None, and leave the existing
value unchanged otherwise. Keep the old_chain check and frame materialization
flow intact.
- Around line 1534-1686: Extract the repeated caller cleanup into a private
helper associated with SuspendedFrame or the trampoline scope, preserving the
order of dropping owned references, calling exit_iframe, and conditionally
releasing and popping the datastack frame; replace every duplicated cleanup
block in ReturnValue and Unwind handling. Also extract the repeated TailCall
SuspendedFrame re-push logic into a helper and use it in all three TailCall
arms, preserving pending-tailcall reference draining and callee transfer.
---
Nitpick comments:
In `@crates/vm/src/vm/mod.rs`:
- Around line 2158-2184: Remove the duplicated depth-gated
check_c_stack_overflow block from enter_iframe, leaving check_recursive_call and
the call to enter_iframe_unchecked intact. Keep the existing C-stack validation
exclusively in enter_iframe_unchecked.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro Plus
Run ID: e3a61024-2055-409e-8bf6-fd0c6e1c48c0
📒 Files selected for processing (11)
.claude/settings.jsoncrates/vm/src/builtins/frame.rscrates/vm/src/builtins/function.rscrates/vm/src/builtins/type.rscrates/vm/src/coroutine.rscrates/vm/src/frame.rscrates/vm/src/object/ext.rscrates/vm/src/protocol/callable.rscrates/vm/src/stdlib/_thread.rscrates/vm/src/vm/mod.rscrates/vm/src/vm/thread.rs
💤 Files with no reviewable changes (1)
- .claude/settings.json
| /// Base pointer of the datastack allocation when this frame and its | ||
| /// localsplus are bump-allocated together. Null for heap-backed frames. | ||
| pub(crate) datastack_base: *mut u8, | ||
| /// Pointer to the owning `Py<FrameObject>`, or null for stack-allocated | ||
| /// frames that have not been materialized yet. | ||
| /// Stored as `usize` for `PyAtomic` compatibility. | ||
| pub(crate) materialized: PyAtomic<usize>, | ||
|
|
||
| /// Lazily-allocated cold data (tracing, debugging, frame inspection). | ||
| /// `None` until first access via `cold()`. | ||
| pub(crate) cold: UnsafeCell<Option<Box<FrameColdData>>>, |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Locate any unsafe Sync/Send impls for the frame types.
rg -nP -C4 'unsafe\s+impl\s+(Sync|Send)\s+for\s+(InterpreterFrame|FrameObject|FrameUnsafeCell)' crates/vm/src
# List every cold() call site with context to classify owner-thread vs cross-thread.
rg -nP -C3 '\.cold\(\)' crates/vm/src
# Check which FrameObject getters guard with check_locals_access.
rg -nP -C6 'fn (f_trace|set_f_trace|f_trace_lines|set_f_trace_lines|f_trace_opcodes|set_f_trace_opcodes)\b' crates/vm/src/builtins/frame.rsRepository: RustPython/RustPython
Length of output: 26925
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the unsafe Sync impl context and the cold() accessor.
sed -n '860,900p' crates/vm/src/frame.rs
sed -n '1260,1290p' crates/vm/src/frame.rs
# Inspect the FrameObject definitions and any unsafe Sync for frame object storage.
rg -n -C3 'struct FrameObject|type FrameObject|unsafe impl (Sync|Send)|pyclass|PyAtomic' crates/vm/src/frame.rs
# Inspect VM current_frame and owner checks around frame access.
sed -n '1130,1165p' crates/vm/src/frame.rs
rg -n -C4 'fn current_frame|current_frame\(' crates/vm/src/vm/mod.rs crates/vm/src/frame.rs
# Read only relevant source definitions and call sites with enough context to see guard usage.
sed -n '620,750p' crates/vm/src/builtins/frame.rs
sed -n '2100,2130p' crates/vm/src/vm/mod.rs
sed -n '2368,2385p' crates/vm/src/vm/mod.rs
sed -n '830,920p' crates/vm/src/builtins/frame.rsRepository: RustPython/RustPython
Length of output: 34515
Make cold() race-safe on FrameObject payloads.
cold() writes a value into UnsafeCell<Option<Box<FrameColdData>>> through a shared reference. The frame types are marked Send/Sync, and tracing/accessor methods such as f_trace, set_f_trace, f_trace_lines, set_f_trace_lines, f_trace_opcodes, and set_f_trace_opcodes reach it from any thread holding the FrameObject without ownership/STW guards before allocation. Concurrent first access can allocate two boxes and leave Py<FrameObject> with a reference to an unshared/dropped value.
Restrict lazy allocation to owner-thread or STW contexts, or publish the FrameColdData pointer atomically so initialization is race-free.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/frame.rs` around lines 871 - 881, Make the lazy initialization
performed by FrameObject::cold race-safe instead of mutating
UnsafeCell<Option<Box<FrameColdData>>> through a shared reference. Either
enforce owner-thread/STW-only access before allocation across cold() and its
tracing/accessor callers, or replace the storage and publication path with
atomic initialization that guarantees one shared FrameColdData allocation is
retained and visible to concurrent readers.
| /// The bytecode loop wants to tail-call into a new frame that has | ||
| /// already been prepared on the datastack. The trampoline reads the | ||
| /// pending frame pointer from `vm.pending_tailcall_frame`. | ||
| TailCall, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find every match on ExecutionResult across the workspace.
rg -nP -C4 'ExecutionResult::' cratesRepository: RustPython/RustPython
Length of output: 16683
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== ExecutionResult declaration and exports/usages =="
rg -n 'pub enum ExecutionResult|ExecutionResult' crates/vm/Cargo.toml crates/vm/src -g '*.rs' | head -200
echo
echo "== workspace Cargo references to vm crate =="
rg -n 'rustpython[-_ ]?vm|rustpython_vm|vm]' Cargo.toml crates/*/Cargo.toml crates/*/*/Cargo.toml 2>/dev/null || true
echo
echo "== match expressions that might pattern-match ExecutionResult without ExecutionResult:: =="
python3 - <<'PY'
import pathlib, re
for p in pathlib.Path('crates').rglob('*.rs'):
text=p.read_text(errors='ignore')
if 'ExecutionResult' not in text:
continue
for i,line in enumerate(text.splitlines(), start=1):
if 'ExecutionResult' in line:
print(f"{p}:{i}:{line}")
PY
echo
echo "== public exports context =="
sed -n '1,200p' crates/vm/src/lib.rs
sed -n '1420,1445p' crates/vm/src/frame.rs
sed -n '80,112p' crates/vm/src/coroutine.rsRepository: RustPython/RustPython
Length of output: 15715
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== candidate release notes files =="
fd -i 'rename|release|changelog|news|changes' . -t f -x sh -c 'echo "--- $1"; sed -n "1,220p" "$1"' sh {}
echo
echo "== TailCall mentions across tracked files =="
rg -n 'TailCall|tailcall|execution result|ExecutionResult' . --glob '!target/**' --glob '!__pycache__/**' | sed -n '1,240p'Repository: RustPython/RustPython
Length of output: 38802
Record the ExecutionResult::TailCall public API addition.
ExecutionResult is public, so external downstream crates that exhaustively match on it will need to handle TailCall. Update the release/compat notes for this breaking API change.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/frame.rs` around lines 1455 - 1458, Update the release or
compatibility notes to record the public ExecutionResult::TailCall variant
addition and identify it as a breaking API change requiring downstream
exhaustive matches to handle the new variant.
| /// Handle an exception propagating into a suspended caller frame in the | ||
| /// trampoline. Adds a traceback entry at the caller's call site, then | ||
| /// tries the caller's exception table via `unwind_blocks`. | ||
| /// | ||
| /// Returns: | ||
| /// - `Ok(None)` — handler found, the caller's `run_iframe` can be re-entered | ||
| /// - `Ok(Some(result))` — handler returned a result (break from the run loop) | ||
| /// - `Err(exc)` — no handler, exception propagates to the next caller | ||
| pub(crate) fn trampoline_handle_exception( | ||
| iframe: &mut InterpreterFrame, | ||
| exception: &PyBaseExceptionRef, | ||
| vm: &VirtualMachine, | ||
| ) -> FrameResult { | ||
| let code: &Py<PyCode> = unsafe { &*iframe.code }; | ||
| let globals: &Py<PyDict> = unsafe { &*iframe.globals }; | ||
| let builtins: &PyObject = unsafe { &*iframe.builtins }; | ||
| let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { | ||
| None | ||
| } else { | ||
| Some(unsafe { &*iframe.func_obj }) | ||
| }; | ||
| let builtins_dict = if globals.class().is(vm.ctx.types.dict_type) { | ||
| builtins | ||
| .downcast_ref_if_exact::<PyDict>(vm) | ||
| .map(|d| unsafe { PyExact::ref_unchecked(d) }) | ||
| } else { | ||
| None | ||
| }; | ||
| let iframe_ptr = iframe as *const InterpreterFrame; | ||
| let mut exec = ExecutingFrame { | ||
| code, | ||
| localsplus: &mut iframe.localsplus, | ||
| locals: &iframe.locals, | ||
| globals, | ||
| builtins, | ||
| builtins_dict, | ||
| lasti: &iframe.lasti, | ||
| iframe: iframe_ptr, | ||
| func_obj, | ||
| prev_line: &mut iframe.prev_line, | ||
| monitoring_mask: 0, | ||
| tailcall_enabled: false, | ||
| }; | ||
|
|
||
| // lasti points past the CallPyExactArgs instruction (+ cache entries). | ||
| // The exception occurred at the previous instruction (the call site). | ||
| let idx = (exec.lasti() as usize).saturating_sub(1); | ||
|
|
||
| // Add traceback entry at the call site. | ||
| if let Some((loc, _end_loc)) = exec.code.locations.get(idx) { | ||
| let next = exception.__traceback__(); | ||
| let new_traceback = PyTraceback::new(next, exec.frame_object(vm), idx as u32 * 2, loc.line); | ||
| exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); | ||
| } | ||
|
|
||
| exec.unwind_blocks( | ||
| vm, | ||
| UnwindReason::Raising { | ||
| exception: exception.clone(), | ||
| }, | ||
| ) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Show the monitoring fires in the recursive unwind path.
rg -nP -C3 'EVENT_PY_UNWIND|fire_py_unwind|EVENT_RERAISE|fire_reraise' crates/vm/src
# Check whether tail-call eligibility consults monitoring state.
rg -nP -C6 'tailcall_enabled|is_generator_like|specialization_eval_frame_active' crates/vm/src/frame.rsRepository: RustPython/RustPython
Length of output: 28309
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the recursive unwind/trace handling and trampoline setup, plus monitoring event handling.
sed -n '2160,2275p' crates/vm/src/frame.rs
sed -n '2320,2380p' crates/vm/src/frame.rs
sed -n '3130,3230p' crates/vm/src/frame.rs
sed -n '3425,3470p' crates/vm/src/frame.rs
# Inspect monitoring event fire helpers.
sed -n '960,1035p' crates/vm/src/stdlib/sys/monitoring.rs
# Inspect tailcall_prepare_frame guard locations.
rg -n "tailcall_prepare_frame|is_generator_like|specialization_eval_frame_active" crates/vm/src/frame.rsRepository: RustPython/RustPython
Length of output: 17703
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the trampoline call instruction implementation, tailcall preparation, and resume path.
sed -n '5800,5865p' crates/vm/src/frame.rs
sed -n '10090,10145p' crates/vm/src/frame.rs
sed -n '2280,2320p' crates/vm/src/frame.rs
# Inspect where RunIfResume instructions or suspended frames re-enter the trampoline.
rg -n -C4 'RunIfResume|trampoline_handle_exception|resume\(' crates/vm/src/frame.rsRepository: RustPython/RustPython
Length of output: 8919
Fire PY_UNWIND monitoring in trampoline_handle_exception.
In recursive frame execution, monitoring::fire_py_unwind is fired when the exception escapes the frame. The trampoline path does not enable tailcall_enabled, so tail-call preparation is skipped and sys.settrace is unaffected, but sys.monitoring still reads vm.state.monitoring_events and expects monitoring events. If this path is reachable with EVENT_PY_UNWIND enabled, emit fire_py_unwind here with idx as u32 * 2 before returning to unwind_blocks.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/frame.rs` around lines 2308 - 2369, Update
trampoline_handle_exception to emit monitoring::fire_py_unwind when
EVENT_PY_UNWIND is enabled, using the computed call-site offset idx as u32 * 2,
immediately before invoking unwind_blocks. Preserve the existing traceback setup
and unwind result propagation.
| let code: &Py<PyCode> = unsafe { &*iframe.code }; | ||
| let globals: &Py<PyDict> = unsafe { &*iframe.globals }; | ||
| let builtins: &PyObject = unsafe { &*iframe.builtins }; | ||
| let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { | ||
| None | ||
| } else { | ||
| Some(unsafe { &*iframe.func_obj }) | ||
| }; | ||
| let builtins_dict = if globals.class().is(vm.ctx.types.dict_type) { | ||
| builtins | ||
| .downcast_ref_if_exact::<PyDict>(vm) | ||
| .map(|d| unsafe { PyExact::ref_unchecked(d) }) | ||
| } else { | ||
| None | ||
| }; | ||
| let iframe_ptr = iframe as *const InterpreterFrame; | ||
| let mut exec = ExecutingFrame { | ||
| code, | ||
| localsplus: &mut iframe.localsplus, | ||
| locals: &iframe.locals, | ||
| globals, | ||
| builtins, | ||
| builtins_dict, | ||
| lasti: &iframe.lasti, | ||
| iframe: iframe_ptr, | ||
| func_obj, | ||
| prev_line: &mut iframe.prev_line, | ||
| monitoring_mask: 0, | ||
| tailcall_enabled: false, | ||
| }; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Extract the repeated ExecutingFrame construction.
The same construction block now appears four times: Py<FrameObject>::with_exec, Py<FrameObject>::yield_from_target, trampoline_handle_exception, and run_iframe. The blocks differ only in builtins_dict and tailcall_enabled. Add one constructor, for example ExecutingFrame::from_iframe(iframe, tailcall_enabled, resolve_builtins_dict, vm), and call it from all four sites.
As per coding guidelines: "When branches differ only in a value but share logic, extract the differing value and invoke the common logic once."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/frame.rs` around lines 2321 - 2350, Extract the duplicated
ExecutingFrame initialization into a shared constructor such as
ExecutingFrame::from_iframe, accepting iframe, tailcall_enabled,
resolve_builtins_dict, and vm. Replace the construction blocks in
Py<FrameObject>::with_exec, Py<FrameObject>::yield_from_target,
trampoline_handle_exception, and run_iframe, passing each site’s differing
builtins_dict behavior and tailcall_enabled value while preserving all existing
field assignments.
Source: Coding guidelines
| /// Prepare a callee frame on the datastack for a TailCall. | ||
| /// Pops args, self_or_null, and callable from the caller's stack, | ||
| /// builds the callee InterpreterFrame, and stores its pointer in | ||
| /// `vm.pending_tailcall_frame`. | ||
| /// | ||
| /// The callable must be at stack position `nargs + 1` (already validated). | ||
| fn tailcall_prepare_frame( | ||
| &mut self, | ||
| nargs: u32, | ||
| self_or_null_is_some: bool, | ||
| vm: &VirtualMachine, | ||
| ) { | ||
| let base = usize::from(self_or_null_is_some); | ||
| let effective_nargs = nargs as usize + base; | ||
|
|
||
| // Peek at the callable (still on the stack) to build the callee | ||
| // frame. The callable stays on the caller's stack until we're done | ||
| // constructing the callee. | ||
| let callable = self.nth_value(nargs + 1); | ||
| let func = callable.downcast_ref_if_exact::<PyFunction>(vm).unwrap(); | ||
|
|
||
| let code: &Py<PyCode> = &func.code; | ||
|
|
||
| let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { | ||
| FrameLocals::lazy() | ||
| } else { | ||
| FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( | ||
| func.globals.clone(), | ||
| )) | ||
| }; | ||
|
|
||
| let callee_iframe = InterpreterFrame::new_on_datastack( | ||
| code, | ||
| &func.globals, | ||
| &func.builtins, | ||
| Some(func.as_object()), | ||
| locals, | ||
| func.closure.as_ref().map_or(&[], |c| c.as_slice()), | ||
| vm, | ||
| ); | ||
|
|
||
| // Move args directly from the caller's stack into callee fastlocals, | ||
| // avoiding an intermediate buffer. | ||
| { | ||
| let fastlocals = callee_iframe.localsplus.fastlocals_mut(); | ||
| for (dst, arg) in fastlocals[base..effective_nargs] | ||
| .iter_mut() | ||
| .zip(self.pop_multiple(nargs as usize)) | ||
| { | ||
| *dst = Some(arg); | ||
| } | ||
| let self_or_null = self.pop_value_opt(); | ||
| debug_assert_eq!(self_or_null.is_some(), self_or_null_is_some); | ||
| if self_or_null.is_some() { | ||
| fastlocals[0] = self_or_null; | ||
| } | ||
| } | ||
|
|
||
| // Pop the callable and transfer ownership to the trampoline via | ||
| // the VM side channel, avoiding a per-frame mutex lock on | ||
| // temporary_refs. | ||
| let callable = self.pop_value(); | ||
| unsafe { &mut *vm.pending_tailcall_refs.get() }.push(callable); | ||
|
|
||
| vm.set_pending_tailcall(callee_iframe); | ||
| } | ||
|
|
||
| /// Prepare a callee frame for a bound method TailCall. | ||
| /// Pops args, self_or_null (null), and callable from the caller's stack, | ||
| /// builds the callee InterpreterFrame with bound_self prepended, and | ||
| /// stores its pointer in `vm.pending_tailcall_frame`. | ||
| fn tailcall_prepare_bound_method_frame( | ||
| &mut self, | ||
| nargs: u32, | ||
| bound_function: PyObjectRef, | ||
| bound_self: PyObjectRef, | ||
| vm: &VirtualMachine, | ||
| ) { | ||
| let effective_nargs = nargs as usize + 1; // +1 for bound_self | ||
|
|
||
| let func = bound_function | ||
| .downcast_ref_if_exact::<PyFunction>(vm) | ||
| .unwrap(); | ||
| let code: &Py<PyCode> = &func.code; | ||
|
|
||
| let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { | ||
| FrameLocals::lazy() | ||
| } else { | ||
| FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( | ||
| func.globals.clone(), | ||
| )) | ||
| }; | ||
|
|
||
| let callee_iframe = InterpreterFrame::new_on_datastack( | ||
| code, | ||
| &func.globals, | ||
| &func.builtins, | ||
| Some(func.as_object()), | ||
| locals, | ||
| func.closure.as_ref().map_or(&[], |c| c.as_slice()), | ||
| vm, | ||
| ); | ||
|
|
||
| // Move args directly from the caller's stack into callee fastlocals. | ||
| let fastlocals = callee_iframe.localsplus.fastlocals_mut(); | ||
| for (dst, arg) in fastlocals[1..effective_nargs] | ||
| .iter_mut() | ||
| .zip(self.pop_multiple(nargs as usize)) | ||
| { | ||
| *dst = Some(arg); | ||
| } | ||
| self.pop_value_opt(); // null (self_or_null) | ||
| let callable = self.pop_value(); // callable (bound method) | ||
| fastlocals[0] = Some(bound_self); | ||
|
|
||
| // Transfer ownership to the trampoline via the VM side channel. | ||
| let refs = unsafe { &mut *vm.pending_tailcall_refs.get() }; | ||
| refs.push(bound_function); | ||
| refs.push(callable); | ||
|
|
||
| vm.set_pending_tailcall(callee_iframe); | ||
| } | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Merge the two tail-call preparation functions.
tailcall_prepare_frame and tailcall_prepare_bound_method_frame share the locals selection, the new_on_datastack call, the argument move loop, and the pending-ref publication. They differ only in where the callee function comes from and what fills fastlocals[0].
Extract one helper that takes the resolved &Py<PyFunction>, the number of stack arguments, and an optional self value, then let both call sites supply those three values.
As per coding guidelines: "When branches differ only in a value but share logic, extract the differing value and invoke the common logic once."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/frame.rs` around lines 10604 - 10726, Merge
tailcall_prepare_frame and tailcall_prepare_bound_method_frame by extracting a
shared helper that accepts the resolved PyFunction, stack argument count, and
optional self value. Move the shared locals creation, datastack frame
construction, fastlocals population, stack cleanup, and pending-tailcall
publication into that helper. Update both existing methods to resolve their
function and self values, then delegate while preserving ownership transfer for
the callable and bound-function references.
Source: Coding guidelines
| let callee_iframe = InterpreterFrame::new_on_datastack( | ||
| code, | ||
| &func.globals, | ||
| &func.builtins, | ||
| Some(func.as_object()), | ||
| locals, | ||
| func.closure.as_ref().map_or(&[], |c| c.as_slice()), | ||
| vm, | ||
| ); | ||
|
|
||
| // Move args directly from the caller's stack into callee fastlocals, | ||
| // avoiding an intermediate buffer. | ||
| { | ||
| let fastlocals = callee_iframe.localsplus.fastlocals_mut(); | ||
| for (dst, arg) in fastlocals[base..effective_nargs] | ||
| .iter_mut() | ||
| .zip(self.pop_multiple(nargs as usize)) | ||
| { | ||
| *dst = Some(arg); | ||
| } | ||
| let self_or_null = self.pop_value_opt(); | ||
| debug_assert_eq!(self_or_null.is_some(), self_or_null_is_some); | ||
| if self_or_null.is_some() { | ||
| fastlocals[0] = self_or_null; | ||
| } | ||
| } | ||
|
|
||
| // Pop the callable and transfer ownership to the trampoline via | ||
| // the VM side channel, avoiding a per-frame mutex lock on | ||
| // temporary_refs. | ||
| let callable = self.pop_value(); | ||
| unsafe { &mut *vm.pending_tailcall_refs.get() }.push(callable); | ||
|
|
||
| vm.set_pending_tailcall(callee_iframe); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
The prepared callee frame leaks its data-stack allocation if this function unwinds.
InterpreterFrame::new_on_datastack reserves the callee frame at line 10635. vm.set_pending_tailcall publishes it at line 10668. Between those two points the frame is owned by nobody:
self.pop_multiple(nargs as usize)panics on stack underflow.- Overwriting a fastlocal slot drops the previous value, and a
__del__implementation can panic.
If either happens, the callee allocation stays on the data stack and release_datastack_frame never runs. The trampoline never sees the frame, so it cannot clean it up either. Compare PyFunction::invoke_prepared_exact_args in crates/vm/src/builtins/function.rs, which pairs the allocation with an unconditional release.
Guard the region, for example with scopeguard::guard that releases the callee frame and pops the base on unwind, and disarm the guard after set_pending_tailcall succeeds. The same gap exists in tailcall_prepare_bound_method_frame at lines 10697-10724.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/frame.rs` around lines 10635 - 10668, The prepared callee frame
is not cleaned up if argument transfer unwinds before publication. In the
frame-preparation flow around InterpreterFrame::new_on_datastack and
vm.set_pending_tailcall, add an unwind guard that releases the callee data-stack
frame and restores the caller stack base, then disarm it only after
set_pending_tailcall succeeds; apply the same protection to
tailcall_prepare_bound_method_frame.
| pub fn run_frame_fast(&self, iframe: &mut crate::frame::InterpreterFrame) -> PyResult { | ||
| match self.with_iframe(iframe, |iframe| crate::frame::run_iframe(iframe, self))? { | ||
| ExecutionResult::Return(value) => Ok(value), | ||
| _ => panic!("Got unexpected result from function"), | ||
| use crate::frame::ExecutionResult; | ||
|
|
||
| let entry_state = self.enter_iframe(iframe)?; | ||
| let result = crate::frame::run_iframe(iframe, self); | ||
|
|
||
| match result { | ||
| Ok(ExecutionResult::Return(value)) => { | ||
| self.exit_iframe(entry_state); | ||
| Ok(value) | ||
| } | ||
| Ok(ExecutionResult::TailCall) => self.run_frame_fast_trampoline(iframe, entry_state), | ||
| Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), | ||
| Err(exc) => { | ||
| self.exit_iframe(entry_state); | ||
| Err(exc) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
The fast-call path has no unwind-safe frame teardown. with_iframe now guards exit_iframe with scopeguard, but the datastack fast-call path does not. If run_iframe unwinds, the frame stays on the thread-local frame chain, recursion_depth stays incremented, and the datastack allocation is never popped or dropped.
crates/vm/src/vm/mod.rs#L1424-L1442: wrapentry_statein ascopeguardaround thecrate::frame::run_iframecall, and apply the same protection to therun_iframecalls and the suspendedframe_stackinsiderun_frame_fast_trampoline.crates/vm/src/builtins/function.rs#L632-L650: guard theiframesorelease_datastack_frameandvm.datastack_pop(base)run whenvm.run_frame_fastunwinds.crates/vm/src/builtins/function.rs#L803-L827: apply the same guard aroundvm.run_frame_fast(iframe)ininvoke_prepared_exact_args.
📍 Affects 2 files
crates/vm/src/vm/mod.rs#L1424-L1442(this comment)crates/vm/src/builtins/function.rs#L632-L650crates/vm/src/builtins/function.rs#L803-L827
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/vm/mod.rs` around lines 1424 - 1442, The fast-call execution
paths need unwind-safe cleanup. In crates/vm/src/vm/mod.rs lines 1424-1442,
guard entry_state with scopeguard around crate::frame::run_iframe, including
each run_iframe call and suspended frame_stack handling in
run_frame_fast_trampoline. In crates/vm/src/builtins/function.rs lines 632-650,
guard the iframe so release_datastack_frame and vm.datastack_pop(base) execute
if vm.run_frame_fast unwinds; apply the same protection around
vm.run_frame_fast(iframe) in invoke_prepared_exact_args at lines 803-827.
| Action::ReturnValue(value) => { | ||
| let Some(caller) = frame_stack.pop() else { | ||
| // All frames consumed — this is the final return. | ||
| return Ok(value); | ||
| }; | ||
| let SuspendedFrame { | ||
| iframe: caller_iframe_ptr, | ||
| entry_state: caller_entry, | ||
| owned_refs: _caller_refs, | ||
| is_entry: caller_is_entry, | ||
| } = caller; | ||
| let caller_iframe = unsafe { &mut *caller_iframe_ptr }; | ||
| caller_iframe.localsplus.push_stack(value); | ||
|
|
||
| let result = crate::frame::run_iframe(caller_iframe, self); | ||
| match result { | ||
| Ok(ExecutionResult::TailCall) => { | ||
| let refs = unsafe { &mut *self.pending_tailcall_refs.get() } | ||
| .drain(..) | ||
| .collect(); | ||
| drop(_caller_refs); | ||
| frame_stack.push(SuspendedFrame { | ||
| iframe: caller_iframe_ptr, | ||
| entry_state: caller_entry, | ||
| owned_refs: refs, | ||
| is_entry: caller_is_entry, | ||
| }); | ||
| action = Action::EnterCallee(self.take_pending_tailcall()); | ||
| } | ||
| Ok(ExecutionResult::Return(value)) => { | ||
| drop(_caller_refs); | ||
| self.exit_iframe(caller_entry); | ||
| if !caller_is_entry { | ||
| unsafe { | ||
| if let Some(base) = caller_iframe.release_datastack_frame() { | ||
| self.datastack_pop(base); | ||
| } | ||
| } | ||
| } | ||
| action = Action::ReturnValue(value); | ||
| } | ||
| Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), | ||
| Err(exc) => { | ||
| drop(_caller_refs); | ||
| self.exit_iframe(caller_entry); | ||
| if !caller_is_entry { | ||
| unsafe { | ||
| if let Some(base) = caller_iframe.release_datastack_frame() { | ||
| self.datastack_pop(base); | ||
| } | ||
| } | ||
| } | ||
| action = Action::Unwind(exc); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Action::Unwind(exc) => { | ||
| let Some(caller) = frame_stack.pop() else { | ||
| return Err(exc); | ||
| }; | ||
| let SuspendedFrame { | ||
| iframe: caller_iframe_ptr, | ||
| entry_state: caller_entry, | ||
| owned_refs: _caller_refs, | ||
| is_entry: caller_is_entry, | ||
| } = caller; | ||
| let caller_iframe = unsafe { &mut *caller_iframe_ptr }; | ||
|
|
||
| let handled = | ||
| crate::frame::trampoline_handle_exception(caller_iframe, &exc, self); | ||
|
|
||
| match handled { | ||
| Ok(None) => { | ||
| // Handler found — resume the caller's dispatch loop. | ||
| let result = crate::frame::run_iframe(caller_iframe, self); | ||
| match result { | ||
| Ok(ExecutionResult::TailCall) => { | ||
| let refs = unsafe { &mut *self.pending_tailcall_refs.get() } | ||
| .drain(..) | ||
| .collect(); | ||
| drop(_caller_refs); | ||
| frame_stack.push(SuspendedFrame { | ||
| iframe: caller_iframe_ptr, | ||
| entry_state: caller_entry, | ||
| owned_refs: refs, | ||
| is_entry: caller_is_entry, | ||
| }); | ||
| action = Action::EnterCallee(self.take_pending_tailcall()); | ||
| } | ||
| Ok(ExecutionResult::Return(value)) => { | ||
| drop(_caller_refs); | ||
| self.exit_iframe(caller_entry); | ||
| if !caller_is_entry { | ||
| unsafe { | ||
| if let Some(base) = | ||
| caller_iframe.release_datastack_frame() | ||
| { | ||
| self.datastack_pop(base); | ||
| } | ||
| } | ||
| } | ||
| action = Action::ReturnValue(value); | ||
| } | ||
| Ok(ExecutionResult::Yield(_)) => { | ||
| panic!("Yield in non-generator frame") | ||
| } | ||
| Err(new_exc) => { | ||
| drop(_caller_refs); | ||
| self.exit_iframe(caller_entry); | ||
| if !caller_is_entry { | ||
| unsafe { | ||
| if let Some(base) = | ||
| caller_iframe.release_datastack_frame() | ||
| { | ||
| self.datastack_pop(base); | ||
| } | ||
| } | ||
| } | ||
| action = Action::Unwind(new_exc); | ||
| } | ||
| } | ||
| } | ||
| Ok(Some(ExecutionResult::Return(value))) => { | ||
| drop(_caller_refs); | ||
| self.exit_iframe(caller_entry); | ||
| if !caller_is_entry { | ||
| unsafe { | ||
| if let Some(base) = caller_iframe.release_datastack_frame() { | ||
| self.datastack_pop(base); | ||
| } | ||
| } | ||
| } | ||
| action = Action::ReturnValue(value); | ||
| } | ||
| Ok(Some(_)) => { | ||
| panic!("Unexpected execution result in trampoline unwind") | ||
| } | ||
| Err(new_exc) => { | ||
| drop(_caller_refs); | ||
| self.exit_iframe(caller_entry); | ||
| if !caller_is_entry { | ||
| unsafe { | ||
| if let Some(base) = caller_iframe.release_datastack_frame() { | ||
| self.datastack_pop(base); | ||
| } | ||
| } | ||
| } | ||
| action = Action::Unwind(new_exc); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Extract the repeated caller-teardown sequence into a helper.
The block drop(_caller_refs); self.exit_iframe(...); if !caller_is_entry { unsafe { if let Some(base) = ...release_datastack_frame() { self.datastack_pop(base); } } } appears six times, and the TailCall re-push block appears three times. The repetition makes it easy to omit one step in a future change, and each omission is a memory-safety defect rather than a cosmetic one.
Add two private helpers on SuspendedFrame or on the trampoline scope, for example finish_frame(&self, entry, iframe_ptr, is_entry) and resuspend(&self, stack, iframe_ptr, entry, is_entry), then call them from every arm.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/vm/mod.rs` around lines 1534 - 1686, Extract the repeated
caller cleanup into a private helper associated with SuspendedFrame or the
trampoline scope, preserving the order of dropping owned references, calling
exit_iframe, and conditionally releasing and popping the datastack frame;
replace every duplicated cleanup block in ReturnValue and Unwind handling. Also
extract the repeated TailCall SuspendedFrame re-push logic into a helper and use
it in all three TailCall arms, preserving pending-tailcall reference draining
and callee transfer.
| if !old_chain.is_null() { | ||
| let prev_iframe = unsafe { &*old_chain }; | ||
| let back_fo = prev_iframe.materialize_chain(self); | ||
| *fo.iframe().cold().retained_back.lock() = Some(back_fo); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
retained_back is overwritten unconditionally here, unlike every other write site.
with_frame (Line 2118), release_datastack_frame, and the f_back chain walker all guard the write with if guard.is_none(). exit_iframe replaces the existing value. If the frame already captured a caller reference during execution, this write discards it and installs the materialize_chain result. Confirm that replacement is intended; otherwise use the same is_none guard for consistent f_back results.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/vm/mod.rs` around lines 2249 - 2253, Update the retained_back
assignment in exit_iframe to preserve an existing caller reference: lock
retained_back, assign the materialize_chain result only when the guard is None,
and leave the existing value unchanged otherwise. Keep the old_chain check and
frame materialization flow intact.
| if self.use_tracing.get() | ||
| && (frame.iframe().trace.lock().is_some() || !self.is_none(&self.profile_func.borrow())) | ||
| && (frame.iframe().cold().trace.lock().is_some() || !self.is_none(&self.profile_func.borrow())) | ||
| { |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Run cargo fmt; Line 2388 exceeds the default rustfmt width.
The condition on Line 2388 is a single long line. Default rustfmt wraps it. Reformat the file before merge.
Note also that cold() allocates the cold block on first access. This check runs on the return path of every traced frame, including frames that only have a profile function and never set trace. Reordering the condition to test self.profile_func first would avoid that allocation.
As per coding guidelines: "Follow default rustfmt style and run cargo fmt for Rust code."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/vm/mod.rs` around lines 2387 - 2389, Reformat the condition in
the traced-frame return path with default rustfmt, and reorder its operands to
check self.is_none(&self.profile_func.borrow()) before accessing
frame.iframe().cold().trace, avoiding unnecessary cold-block allocation for
profile-only frames while preserving the existing logic.
Source: Coding guidelines
Summary
Move 10 rarely-used fields from
InterpreterFrameinto a lazily-allocatedFrameColdDatastruct, reducing per-frame initialization cost on the hot call path.Stacked on #8431 — merge #8431 first, then this PR.
Changes
InterpreterFramecarried ~200+ bytes of tracing/debugging fields that are only accessed when:sys.settrace()/sys.setprofile()is activef_back,f_locals,f_trace) occursThese 10 fields are now behind a single
UnsafeCell<Option<Box<FrameColdData>>>(8 bytes):trace,trace_lines,trace_opcodestemporary_refs,f_locals_hidden_overlay,f_extra_localsescaped,retained_backpending_stack_pops,pending_unwind_from_stackThe
cold()accessor lazily allocates on first use. Frames that never trigger tracing or debugging pay zero allocation cost. GC traversal skips cold data when it has not been allocated.Files changed
frame.rs—FrameColdDatastruct, field migration,cold()accessor, GC traverse updatebuiltins/frame.rs— Allf_trace,f_trace_lines,f_trace_opcodes,f_back,clear()accessesobject/ext.rs—swap_to_temporary_refs()accessesprotocol/callable.rs— Trace event handlingvm/mod.rs—exit_iframe()cleanup, tracing hooksbuiltins/type.rs—__bases__setter temporary_refsstdlib/_thread.rs— Cross-thread frame materializationPerformance
Incremental call overhead: best measurement 26.8 ns/call (target ≤30 ns).
All frame/traceback/generator/call tests pass.
Summary by CodeRabbit
Performance
Stability
Chores