Skip to main content

1637. gopy frame

What we are porting

Python/frame.c (~700 lines) plus the _PyInterpreterFrame declarations in Include/internal/pycore_frame.h. The frame is the runtime activation record the eval loop reads and writes on every instruction: it owns the value stack, the fast locals array, the current instruction pointer, the previous frame, and a back-pointer to the code object.

The Python-visible frame object (the one sys._getframe() returns) is a thin wrapper over _PyInterpreterFrame and lives in Objects/frameobject.c (spec 1687). This spec covers the interpreter frame; the user-visible wrapper depends on it.

Go shape

// Frame is the interpreter-side activation record. Mirrors
// _PyInterpreterFrame from Include/internal/pycore_frame.h.
//
// Frames live on a per-thread chunk-allocated stack (see
// frame.Chunk). They are not GC'd through Go's heap; the eval
// loop pushes and pops them explicitly so the layout matches
// CPython's _PyThreadState_PushFrame call shape.
type Frame struct {
Code *object.Code // the code being executed
Globals object.Object // module globals dict
Builtins object.Object // module builtins dict
Locals object.Object // locals dict (None for fast-locals frames)
Func *object.Function // the function object, if any
Previous *Frame // caller frame (frame chain)
InstrPtr int // current instruction index
PrevInstr int // for RESUME after yield
StackTop int // index into LocalsPlus where stack begins
LocalsPlus []stackref.Ref // locals + cells + free + value stack
Owner OwnerKind // generator, frame object, eval loop
ReturnOffset int // for inline frames: caller's resume point
YieldOffset int // for generators: last YIELD_VALUE offset
}

// OwnerKind tracks which subsystem holds the canonical pointer to
// this frame. Mirrors _PyFrameOwner.
type OwnerKind uint8
const (
OwnedByEval OwnerKind = iota
OwnedByGenerator
OwnedByThread // top-of-stack frame
OwnedByFrameObj // a Python frame object refs this
OwnedByCstack // C-stack-allocated, do not free
)

Locals-plus layout

CPython packs locals, cells, free vars, and the value stack into one contiguous array localsplus. The eval loop indexes into it by offset:

[ 0 .. nlocals ) fast locals
[ nlocals .. nlocals+ncells ) cell variables
[ ... .. nlocals+ncells+nfree ) free variables
[ ... .. ... + co_stacksize ) value stack

We mirror this with one Go slice:

// SizeFor computes the LocalsPlus length needed for the given
// code object. Mirrors _PyFrame_NumSlotsForCodeObject.
func SizeFor(co *object.Code) int {
return co.NLocalsPlus + co.StackSize
}

Frame stack

CPython allocates frames out of a per-thread chunk allocator (_PyThreadState_AllocateFrame). We mirror this with a Go-side chunk allocator backed by a []Frame arena per Thread:

// Chunk is a per-thread frame arena. Mirrors _PyStackChunk from
// pycore_pystate.h. Frames are bumped into chunks; chunks chain
// when full.
type Chunk struct {
Frames []Frame
Top int
Prev *Chunk
}

// Push allocates a frame in ts's chunk stack, links it to the
// current top of the frame chain, and returns it. Mirrors
// _PyThreadState_PushFrame.
func Push(ts *state.Thread, co *object.Code) *Frame

// Pop unlinks the top frame and returns its slot to the chunk.
// Mirrors _PyThreadState_PopFrame.
func Pop(ts *state.Thread)

The chunk allocator is a thin wrapper over a Go slice. Go's escape analysis would normally heap-allocate Frame but the chunk arena keeps allocations bounded and amortized.

Generator frames

Generator frames are not freed when the eval loop returns to the caller. They are owned by the generator object and revived on g.send(...). The generator object holds the *Frame directly.

// CopyToGenerator copies the live frame state into a Generator
// object's frame slot, transferring ownership. Mirrors
// _PyGen_CopyToFrame from Objects/genobject.c, lifted here so the
// frame side owns the layout knowledge.
func CopyToGenerator(g *object.Generator, src *Frame)

When RESUME runs in a generator frame, the eval loop reads from the generator's frame slot rather than from the per-thread chunk.

Frame teardown on exception

When an exception escapes a frame, the eval loop walks the frame chain calling clear on each frame. clear decrefs every locals slot, every cell, every free var, and every live stack value. This mirrors _PyFrame_ClearExceptCode from frame.c.

File mapping

C sourceGo target
Python/frame.cvm/frame/frame.go
Include/internal/pycore_frame.h (struct)vm/frame/frame.go
Python/frame.c (chunk alloc)vm/frame/chunk.go
Python/frame.c (clear / unlink)vm/frame/teardown.go

Note: the user-visible frame Python object (the _PyFrame_Type in Objects/frameobject.c) lives in spec 1687. This spec covers only the interpreter-side _PyInterpreterFrame.

Checklist

Status legend: [x] shipped, [ ] pending, [~] partial / scaffold, [n] deferred / not in scope this phase.

Files

  • frame/frame.go: Frame struct, OwnerKind enum, SizeFor, count helpers (NLocalsOf/NCellsOf/NFreeOf/NLocalsPlusOf), layout-offset helpers (CellsStart/FreesStart/StackStart), Init, stack ops (PushStack/PopStack/PeekStack), local ops (SetLocal/LocalAt). (Flat layout per project convention.)
  • frame/chunk.go: Chunk per-thread arena, FrameStack, Push, Pop, chunk-chain growth. 32-frame slabs.
  • Frame.Clear (in frame/frame.go): closes every stackref in LocalsPlus and resets Code/Globals/Builtins/Locals/Func. Folded into the existing file rather than a separate teardown.go to keep the Frame struct and its lifecycle in one place.
  • Frame.Suspend/Frame.Resume + FrameStack.Detach for generator ownership transfer. The objects.Generator wrapper that consumes the detached frame still lands in 1687.
  • frame/frame_test.go: SizeFor + layout offsets, Init, stack ops round-trip, local access, push/pop, chain walk across chunk-boundary growth.

Surface guarantees

  • [~] Push followed by Pop is amortized O(1) on the chunk arena and grows across chunk boundaries. Pinned structurally by TestFrameStackPushPop and TestFrameStackChunkGrowth. The test_call.py corpus comparison waits on the cpython_smoke harness (1630).
  • Frame chain (Previous) walk produces the caller sequence for nested calls, including across chunk-boundary growth. Pinned by TestFrameStackChunkGrowth (walks Previous for ChunkSize+5 frames).
  • Generator suspend / resume preserves every stack slot, every fast local, and the instruction pointer. Pinned by TestFrameSuspendResume and TestFrameStackDetach.
  • Clear releases every owned slot. Pinned by TestFrameClearReleasesRefs (LocalsPlus all-null after Clear, Code/Globals/Builtins/Locals/Func/Previous all nil).
  • LocalsPlus layout: locals at [0:NLocals), cells at [NLocals:NLocals+NCells), frees at [NLocals+NCells:NLocalsPlus), stack at [NLocalsPlus:NLocalsPlus+StackTop). Pinned by TestSizeFor (CellsStart/FreesStart/StackStart plus NLocalsPlusOf).

Cross-block dependencies

  • [~] object.Code carries the count info via Varnames, Cellvars, Freevars, Stacksize. The named accessors (NLocalsPlus, NLocals, NCells, NFree) live on the frame side as helpers. The Objects-side fields land alongside the rest of 1687.
  • object.Generator exposes a frame slot the eval loop can write to (Objects spec 1687). Frame side ships Suspend/Resume/FrameStack.Detach already; the wrapper type is pending.
  • object.Function carries the closure cells the frame receives at push time (Objects spec 1685). Function exists, cell wiring still pending.

Out of scope for v0.6

  • Inlined CALL frames (CPython's "inline" call optimization that avoids a real frame push). v0.6 always pushes a real frame. Inline frames land alongside the specializer in v0.11.
  • Async-generator-specific frame paths beyond what plain generators need. The async-generator object lives in 1687.
  • Free-threaded frame ownership transitions. Lives in v0.14.

Cross-references

  • Eval loop that reads / writes the frame: 1636.
  • Stack reference values stored in LocalsPlus: 1638.
  • User-visible frame object wrapper: 1687.
  • Generator / coroutine objects: 1687.