Skip to main content

1636. gopy eval loop

What we are porting

Two files, ~10k lines total:

  • Python/ceval.c (~9000 lines): the Tier-1 eval loop. Entry, dispatch, exception unwind, frame push/pop on call and return, generator resume, eval breaker poll points, and the small set of hand-written opcodes that do not live in bytecodes.c (RESUME, ENTER_EXECUTOR, INSTRUMENTED_*).
  • Python/ceval_macros.h (~700 lines): macros the generated dispatch arms expand against. STACK_GROW, GETLOCAL, SETLOCAL, TARGET, DISPATCH, JUMPBY, INSTRUCTION_SIZE, the inline-cache walk macros, deopt machinery.

The generated dispatch arms themselves live in vm/opcodes_gen.go (spec 1621). This spec covers everything around them: the loop that calls them, the helpers they expand against, and the unwind logic when one of them returns an error.

Go shape

Top-level entry in vm/eval.go:

// Eval runs frame f to completion under thread state ts and
// returns the value the frame produced (RETURN_VALUE) or the
// error that escaped (uncaught exception).
//
// Mirrors _PyEval_EvalFrameDefault from Python/ceval.c.
func Eval(ts *state.Thread, f *frame.Frame) (object.Object, error)

// EvalCode is the convenience wrapper that builds a frame from a
// code object plus globals/locals and calls Eval. Mirrors
// PyEval_EvalCode.
func EvalCode(ts *state.Thread, co *object.Code, globals, locals object.Object) (object.Object, error)

The eval state is a goroutine-local struct that survives the whole call:

// evalState is the per-call state the dispatch arms read and write.
// Mirrors the locals at the top of _PyEval_EvalFrameDefault.
type evalState struct {
ts *state.Thread
f *frame.Frame // current frame
pc int // instruction offset within f.code
stack []stackref.Ref
sp int // stack pointer
fastlocals []stackref.Ref // f.localsplus alias
err *errors.Exception
breaker uint32 // eval-breaker shadow read each DISPATCH
}

Dispatch shape

CPython uses computed gotos when the compiler supports them and falls back to a switch otherwise. Go has no computed goto; we use a switch with a function-call indirection only for the rare adaptive paths. The hot loop is:

for {
inst := e.f.code.Instructions[e.pc]
op := opcode.Op(inst >> 8)
oparg := uint32(inst & 0xff)
if e.breaker != 0 {
if err := e.handleEvalBreaker(); err != nil {
return e.unwind(err)
}
}
next, err := e.dispatch(op, oparg)
if err != nil {
if v, handled := e.handleException(err); handled {
continue
} else {
return v, err
}
}
e.pc = next
}

dispatch is the generated function from 1621.

Exception unwind

Python/ceval.c walks the code object's exception table to find a handler when an opcode raises. The walk format is PEP 657 and was already ported in 1628 (assemble) for the writer side. The reader side ports here:

// handleException walks the exception table starting at e.pc to
// find a handler. Returns (recovered_pc, true) on hit and
// (zero, false) on miss. Mirrors _PyFrame_GetExceptionTableHandler.
func (e *evalState) handleException(err error) (object.Object, bool)

The exception table walk is a varint decode against co.ExceptionTable. Same algorithm CPython uses; the byte format is what 1628 emits.

Eval breaker

CPython's eval breaker is a bitmask of pending things the eval loop must react to: signals, GIL drop requests, async exceptions, GC requests, profiler attach, monitoring tool installs.

Detail lives in 1639. The eval-loop side just polls the bit and calls into vm/gil.go when it is set.

Special opcodes (hand-written)

A small set of opcodes are not in bytecodes.c. They live in vm/eval.go directly:

  • RESUME: re-enter a frame after a yield. Reads f.PrevInstr, advances past RESUME, polls eval breaker.
  • INSTRUMENTED_RESUME, INSTRUMENTED_*: monitoring hooks (PEP 669). For v0.6 these reduce to their non-instrumented base case.
  • ENTER_EXECUTOR: Tier-2 entry. For v0.6 this is an unconditional fall-through (the executor table is empty).

File mapping

C sourceGo target
Python/ceval.cvm/eval.go
vm/eval_unwind.go (exception table walk)
vm/eval_call.go (CALL trampoline, frame push)
vm/eval_resume.go (RESUME, generator re-entry)
Python/ceval_macros.hvm/dispatch.go (DISPATCH, JUMPBY, GETLOCAL...)
Python/ceval.c (helpers)vm/eval_helpers.go (deoptHere, stack push/pop)

Checklist

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

Files

  • vm/eval.go: Eval, EvalCode, the dispatch loop driver, evalState struct, fetch with EXTENDED_ARG accumulation, push/pop/peek shortcuts, advance/jumpBy, local helpers.
  • vm/eval_unwind.go: PEP 657 exception-table walk in handleException (drives stack truncation + handler jump). unwind / handleEvalBreaker still placeholder for #161.
  • vm/exctable.go: readExcVarint + findExcHandler, round-tripped against compile/assemble_exceptions.go in vm/exctable_test.go. End-to-end parity panel in vm/exception_table_parity_test.go runs curated handler-range fixtures through compile.AssembleExceptionTable, then walks every byte offset to assert the reader reproduces the writer's (start, end, target, depth, lasti) tuples.
  • [~] vm/eval_call.go: CALL trampoline, frame push on call, frame pop on return. Wires objects.FunctionType.Call to push a frame, bind positional+keyword args, fill defaults, and re-enter Eval. Vectorcall plumbing waits on the abstract layer (#161).
  • [~] vm/eval_resume.go: RESUME with eval-breaker poll. Generator re-entry / RETURN_GENERATOR blocked on objects.Generator (1687).
  • vm/dispatch.go: dispatch driver returning typed notImplemented{op} until B2-B5 generates the real arms.
  • vm/threadstate.go: per-Thread VM state (breaker, frame stack, pending-call queue) keyed by *state.Thread.
  • vm/eval_helpers.go: incref, decref, newref, decrefInputs (Go-GC no-ops), plus iterToSlice, slice helpers, intrinsics dispatch tables. (B6)
  • [~] vm/eval_test.go: surface tests for ErrNotImplemented wrap, opcode-name in error message, EXTENDED_ARG fetch, lazy threadstate init, plus per-arm smoke tests (CALL builtin, MAKE_FUNCTION + Call, UNPACK_SEQUENCE, LIST_APPEND, CALL_INTRINSIC_1 list-to-tuple, CALL_KW, BINARY_SLICE). Wider coverage comes after B2-B5 generates arms.

Opcode panel (Tier-1, unspecialized)

Each row flips to [x] once its switch arm in vm/opcodes_gen.go runs without panicking and vm/eval_test.go covers the happy path. Adaptive variants (*_INT, *_STR, ...) inherit their parent row.

  • Stack ops: NOP, RESUME, POP_TOP, PUSH_NULL, COPY, SWAP, POP_BLOCK, POP_ITER, NOT_TAKEN, TO_BOOL.
  • Constant / fast load: LOAD_CONST, LOAD_SMALL_INT, LOAD_FAST, LOAD_FAST_BORROW, LOAD_FAST_CHECK, LOAD_FAST_AND_CLEAR, LOAD_FAST_LOAD_FAST, LOAD_FAST_BORROW_LOAD_FAST_BORROW, STORE_FAST, STORE_FAST_LOAD_FAST, STORE_FAST_STORE_FAST, DELETE_FAST. (RETURN_VALUE lives here too for the v0.6 starter.)
  • Global / name: LOAD_GLOBAL, LOAD_NAME, STORE_GLOBAL, STORE_NAME, DELETE_GLOBAL, DELETE_NAME.
  • Closure / cell: LOAD_DEREF, STORE_DEREF, DELETE_DEREF, LOAD_FROM_DICT_OR_DEREF, MAKE_CELL, COPY_FREE_VARS.
  • [~] Attribute / subscript: STORE_SUBSCR, DELETE_SUBSCR shipped; BINARY_OP NB_SUBSCR routes through getItem against MappingMethods.GetItem / SequenceMethods.GetItem. LOAD_ATTR, STORE_ATTR, DELETE_ATTR route through objects.GetAttr / SetAttr / DelAttr (which dispatch through tp_getattro / tp_setattro). LOAD_SUPER_ATTR, LOAD_SPECIAL still wait on the descriptor / method-resolution work in 1685.
  • [~] Arithmetic: BINARY_OP covers Add, Sub, Mul, TrueDivide, FloorDivide, Remainder, Power, And, Or, Xor, Lshift, Rshift, and the Subscr sub-op; the matching inplace forms (NB_INPLACE_*) reuse the non-inplace slot since Int is immutable. MatMul still returns TypeError pending its own slot. UNARY_NEGATIVE, UNARY_NOT, UNARY_INVERT, COMPARE_OP, IS_OP, CONTAINS_OP (sequence-aware, falls back to iterator walk). Floor and modulo use Python sign-of-divisor semantics; true divide always returns float; numericForward honors the NotImplemented sentinel so an int + float pair falls through to the float slot.
  • [~] Iteration: GET_ITER, FOR_ITER, END_FOR shipped. GET_AITER, GET_ANEXT defer to async (#165).
  • [~] Containers: BUILD_LIST, BUILD_TUPLE, BUILD_MAP, BUILD_STRING, LIST_APPEND, LIST_EXTEND, MAP_ADD, DICT_UPDATE, DICT_MERGE. BUILD_SET returns a TypeError pending the set port (1681). LIST_TO_TUPLE lands via the intrinsic dispatch (CALL_INTRINSIC_1).
  • Unpacking: UNPACK_SEQUENCE, UNPACK_EX.
  • Slicing: BINARY_SLICE, STORE_SLICE, BUILD_SLICE.
  • Control flow: JUMP_FORWARD, JUMP_BACKWARD, JUMP, JUMP_NO_INTERRUPT, POP_JUMP_IF_TRUE, POP_JUMP_IF_FALSE, POP_JUMP_IF_NONE, POP_JUMP_IF_NOT_NONE, JUMP_BACKWARD_NO_INTERRUPT, RETURN_VALUE. (RETURN_GENERATOR is held back for #161 alongside the CALL trampoline; RETURN_CONST was removed in 3.14.)
  • Calls: CALL, CALL_KW, CALL_FUNCTION_EX, MAKE_FUNCTION, SET_FUNCTION_ATTRIBUTE. KW_NAMES is folded into CALL_KW in 3.14 (the kwnames tuple ships on the stack).
  • [~] Generator / coroutine: END_SEND shipped (drops the receiver and forwards the value, matching the yield from cleanup contract). YIELD_VALUE, SEND, GET_AWAITABLE, GET_YIELD_FROM_ITER, RETURN_GENERATOR block on objects.Generator (#165).
  • Async: BEFORE_ASYNC_WITH, BEFORE_WITH, WITH_EXCEPT_START, CLEANUP_THROW, LOAD_SPECIAL. (#165)
  • [~] Exception handling: RAISE_VARARGS, RERAISE, PUSH_EXC_INFO, POP_EXCEPT, CHECK_EXC_MATCH shipped. CHECK_EG_MATCH and LOAD_ASSERTION_ERROR wait on the exception module port (1686). The unwind uses Go errors as exception values until the exception class hierarchy lands.
  • [~] Class / type: GET_LEN, EXIT_INIT_CHECK shipped (the latter raises TypeError when __init__ returns non-None, matching CPython). LOAD_BUILD_CLASS, MATCH_CLASS, MATCH_MAPPING, MATCH_SEQUENCE, MATCH_KEYS defer to the class / pattern-matching ports.
  • f-strings: FORMAT_SIMPLE, FORMAT_WITH_SPEC, CONVERT_VALUE, BUILD_STRING. Empty spec routes through Str; non-empty spec waits on PyObject_Format. FORMAT_VALUE was retired in 3.14.
  • Imports: IMPORT_NAME, IMPORT_FROM, IMPORT_STAR. Defer to the import system port (1683).
  • [~] Type alias / TypeVar / PEP 695: INTERPRETER_EXIT, LOAD_CLOSURE, LOAD_LOCALS, LOAD_FROM_DICT_OR_GLOBALS, LOAD_FROM_DICT_OR_DEREF, LOAD_COMMON_CONSTANT shipped. TYPE_ALIAS, INSTRUMENTED_LOAD_SUPER_ATTR reach through the intrinsics table; bodies ship in 1689.
  • Intrinsic dispatch: CALL_INTRINSIC_1, CALL_INTRINSIC_2 routed through intrinsics.UnaryTable / BinaryTable. Most helpers stay stubs until their owning blocks land; UnaryListToTuple is wired.

Surface guarantees

  • EvalCode(ts, co, globals, nil) runs hand-built bytecode end-to-end through the v0.6 release gate (gopy -c "print(1+2)" prints 3). Pinned by vmtest/smoke_test.go::TestSmokeReleaseV06 and the vmtest/gate_test.go panel (Constant, Arithmetic, IfElse, ListBuild, FString). The v0.5 disassembly golden corpus comparison waits on parser rule-body emission (1640).
  • [~] Exception table walk: round-trip parity with the writer side pinned by vm/exctable_test.go. The CPython golden corpus comparison still needs vm/exception_table_test.go.
  • [n] Generator state machine: g = gen(); next(g); next(g); ... visits the same bytecode offsets in the same order as CPython. Defers to objects.Generator (1687) plus the pending YIELD_VALUE / SEND / RETURN_GENERATOR handlers tracked in #193.
  • [n] Async coroutine state machine: same shape as generator, same defer.
  • Eval breaker fires at every RESUME and at every backward JUMP_BACKWARD, plus the unconditional top-of-loop poll; JUMP_BACKWARD_NO_INTERRUPT skips the per-arm poll. Pinned by vm/eval_breaker_test.go (top-of-loop, JUMP_BACKWARD, RESUME, no-interrupt skip, no-bit no-drain).
  • Line table walk: round-trip parity with the writer pinned by vm/positions_test.go, which builds curated *compile.Sequence fixtures, runs them through compile.AssembleLineTable, and walks every byte offset to assert the reader reproduces the writer's (line, endLine, column, endColumn) tuples across all five PEP 626 record formats (short, oneline, no-column, long, none) plus multi-record split spans.
  • EvalCode is goroutine-safe for distinct calls; one evalState is not safe for concurrent use. Pinned by TestEvalCodeGoroutineSafety (32 goroutines × shared *Code).

Out of scope for v0.6

  • INSTRUMENTED_* opcodes execute their non-instrumented base case. Real monitoring lands in 1634 at v0.9.
  • ENTER_EXECUTOR: falls through. Tier-2 lands in 1632 at v0.12.
  • Adaptive specialization: adaptive variants reduce to base case. Lands in 1631 at v0.11.

Cross-references

  • Generated dispatch table: 1621.
  • Frame storage: 1637.
  • Stack reference values: 1638.
  • GIL and eval breaker: 1639.
  • Intrinsic dispatch: 1635.
  • Vectorcall protocol: 1684 (Objects block).
  • Code / Frame / Generator / Cell objects: 1687 (Objects block).