Split InterpreterFrame hot/cold fields into FrameColdData - #8434
Conversation
📝 WalkthroughWalkthrough
ChangesFrame cold-state storage
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 optimizes call overhead by moving rarely-used tracing/debug/inspection state out of InterpreterFrame into a lazily-initialized “cold” allocation (FrameColdData), aiming to reduce per-call frame initialization costs while still supporting tracing, frame inspection, and GC traversal when needed.
Changes:
- Introduces
FrameColdDataand routes tracing/inspection-related fields throughInterpreterFrame::cold(). - Updates tracing hooks and frame APIs (
f_trace*,f_back, temporary ref handling, retained backrefs) to use the cold storage. - Adjusts GC traversal to skip cold data when not allocated.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/vm/src/frame.rs | Adds FrameColdData, migrates fields into lazy cold storage, updates traversal and frame APIs accordingly. |
| crates/vm/src/builtins/frame.rs | Updates Python-visible frame attribute accessors and clear/backref logic to read/write cold fields. |
| crates/vm/src/object/ext.rs | Routes swap_to_temporary_refs() to store old refs in cold temporary_refs. |
| crates/vm/src/protocol/callable.rs | Updates tracing dispatch logic to consult cold trace_opcodes / clear cold trace. |
| crates/vm/src/vm/mod.rs | Updates retained-back capture, temporary-ref cleanup, and trace event handling to use cold fields. |
| crates/vm/src/builtins/type.rs | Updates __bases__ setter keep-alive path to use cold temporary_refs. |
| crates/vm/src/stdlib/_thread.rs | Updates cross-thread frame materialization backref retention to use cold retained_back. |
Suppressed comments (1)
crates/vm/src/frame.rs:1582
has_escaped()currently callscold(), which forces allocation ofFrameColdDataeven for the common case where nothing escaped (e.g. coroutine/generator close checks this routinely). That undermines the stated goal of paying zero allocation cost when tracing/inspection isn’t used.
has_escaped() can treat “cold not allocated yet” as false and avoid allocating/locking in the hot path.
pub(crate) fn has_escaped(&self) -> bool {
self.iframe().cold().escaped.load(atomic::Ordering::Acquire)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Opcode events are only dispatched when f_trace_opcodes is set. | ||
| if is_opcode_event && !*frame_ref.iframe().trace_opcodes.lock() { | ||
| if is_opcode_event && !*frame_ref.iframe().cold().trace_opcodes.lock() { | ||
| return Ok(None); | ||
| } |
| 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())) | ||
| { |
| 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: 1
🤖 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 1164-1170: Update InterpreterFrame::cold to replace the
UnsafeCell-based lazy initialization with
std::sync::OnceLock<Box<FrameColdData>>. Initialize the OnceLock through its
thread-safe one-time initialization API and return the stored FrameColdData
reference, preserving default allocation on first use and ensuring concurrent
callers share a single allocation.
🪄 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: 224ac896-8746-45d3-acd9-99e6b4d3580d
📒 Files selected for processing (7)
crates/vm/src/builtins/frame.rscrates/vm/src/builtins/type.rscrates/vm/src/frame.rscrates/vm/src/object/ext.rscrates/vm/src/protocol/callable.rscrates/vm/src/stdlib/_thread.rscrates/vm/src/vm/mod.rs
896cca5 to
e48d496
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/vm/src/frame.rs:1173
InterpreterFrame::cold()performs unsynchronized lazy initialization viaUnsafeCell<Option<...>>. Underfeature="threading",InterpreterFrameisSend + Sync(seeunsafe impl Send/Sync for InterpreterFramein this file), andFrameObjectAPIs likeframe.clear()explicitly support being called from a different thread. That combination means two threads can race to initializecold, which is UB (data race) even though the fields insideFrameColdDataare mutex/atomic protected.
Consider switching cold to a thread-safe one-time init primitive already used in this repo (e.g. rustpython_common::lock::OnceCell/OnceLock) so initialization is race-free, and adjust the GC traverse path to use cold.get() instead of raw pointer reads.
/// 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())) }
}
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
e48d496 to
ff53f27
Compare
Summary
Move 10 rarely-used fields from
InterpreterFrameinto a lazily-allocatedFrameColdDatastruct, reducing per-frame initialization cost.Part of youknowone#40 (call overhead optimization).
Motivation
InterpreterFramecarries ~200+ bytes of tracing/debugging fields that are only accessed when:sys.settrace()/sys.setprofile()is activef_back,f_locals,f_trace) occursOn every Python call, all these fields were initialized even though the vast majority of calls never touch them.
Changes
These 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—f_trace,f_trace_lines,f_trace_opcodes,f_back,clear()accessesobject/ext.rs—swap_to_temporary_refs()protocol/callable.rs— Trace event handlingvm/mod.rs— Frame cleanup, tracing hooksbuiltins/type.rs—__bases__setter temporary_refsstdlib/_thread.rs— Cross-thread frame materializationTesting
All frame/traceback/generator/call/sys/json tests pass.
Summary by CodeRabbit