v0.12.0: Tier-2 optimizer (trace + uop interpreter)#20
Conversation
emitCmpOp emitted the bare 0..5 kind as the COMPARE_OP oparg. Dispatch in vm/eval_simple.go reads `(oparg >> 5) & 0xf` to recover the kind. With the bare encoding `1 == 1` evaluated as `1 < 1` and returned False. Pre-encode the constants as `(cmp << 5) | compare_masks[cmp]` so the high 5 bits carry Py_LT..Py_GE and the low 4 bits carry the TO_BOOL mask CPython's flowgraph pass also relies on.
v0.11 merged with the lint check red. Fix the backlog before v0.12 work opens new files: * gofmt-realign monitor/events.go, specialize/backoff_test.go, specialize/deopt.go, vm/legacy_tracing.go. * Rename objects.Type_ -> objects.ExactType (revive var-naming) and the normalizeEventSet `max` parameter to `maxBits` (redefines-builtin). * Drop redundant `var x objects.Object = ...` declarations and unconvert calls in vm/legacy_tracing.go and vm/instrument_fire.go. * Drop the unused `ev` parameter on monitor.addTools/removeTools and the unread bool result of specializeClassCall and the bumpVersion return value (unparam). * Combine adjacent appends in monitor/line_test.go and vm/instrument_line_test.go (gocritic) and drop the redundant `|0` flagged by SA4016. * Extend .golangci.yml exclusions for compile/codegen_expr_op.go (NB_*) and compile/instrseq.go (MIN_*) which mirror CPython enum spelling, and for monitor.forceInstrument / BuildSysMonitoring / vm.specializeAt whose branch trees mirror Python/instrumentation.c and Python/specialize.c.
Code grows an Executors slice the install path uses to thread trace projections back into the frame, Dict tracks a small mutationCount the watcher fans into and exposes EntryAt for the analysis pass, Function gains GetVersionForCurrentState mirroring the CPython helper, and type_specialize sprouts the modify hook the type watcher uses.
The optimizer needs to read the current function (for the version stamp on the rewritten globals guard) and the per-interpreter canonical builtins dict (for the custom-builtins bail). Add the FrameFunc accessor on the interface, implement it on frame.Frame, fix up the test fakes, and park Builtins / BuiltinDictMutations on state.Interpreter.
Every warm hit on JUMP_BACKWARD calls tryWarmupTier2, which routes to optimizer.Optimize. On success the install site flips to ENTER_EXECUTOR and the next hit lands in enterExecutor. With no uop dispatcher yet the executor arm deopts back to whatever Tier-1 opcode the install path stashed in Executor.VMData.
Port the 256-bit / K=6 dependency bloom (Python/optimizer.c add_to_trace / dependency-tracking helpers), the per-code Executors side table with has_space_for_executor / get_index_for_executor / insert_executor, the AllocateExecutor / ExecutorInit / Detach / Clear surface, and the two-phase pending-deletion dance. Invalidation walks the side table per Code and fans into ExecutorsInvalidateDependency.
translate_bytecode_to_trace runs branch-for-branch with CPython, including the lookupMacro identity fallback and the _START_EXECUTOR / _MAKE_WARM / _JUMP_TO_TOP prelude. The lattice mirrors optimizer_symbols.c with SymMeet / SymJoin and the per-tag switches kept verbatim so the gate fixtures stay aligned. dis.dis gains an ENTER_EXECUTOR row matching upstream output.
Port the three-phase orchestrator from optimizer_analysis.c: remove_unneeded_uops drops _SET_IP / _CHECK_VALIDITY rows where no escape can reach since _START_EXECUTOR, collapses load-then-pop, and resurrects _SET_IP ahead of escapes. removeGlobals folds LOAD_GLOBAL_MODULE / _BUILTINS / _LOAD_ATTR_MODULE into inline constants, rewrites the first _GUARD_GLOBALS_VERSION into _CHECK_FUNCTION stamped with the function version, and registers the dict against the globals watcher. globalsWatcherCallback / typeWatcherCallback land on ExecutorsInvalidateDependency and unsubscribe.
Two gate suites: optimizer_gate_test.go drives Optimize against a synthetic warm code object and confirms the trace lands in the side table, analysis_gate_test.go feeds shaped uop streams through Analyze and asserts the rewrites match the per-pass expectations. Also park the optimizer-specific golangci exemptions next to the existing per-path lint carve-outs.
Parses pycore_uop_ids.h and pycore_uop_metadata.h, emits optimizer/uop_ids_gen.go and optimizer/uop_meta_gen.go, and ships a drift check so regenerated tables can not silently fall behind upstream.
Master regex alternation lexer with longest-first operator ordering, 1-based column tracking via linestart=-1 sentinel, all C keywords plus the inst / op / macro / label / spilled DSL keywords plus the annotation set (pure, replicate, tier1, tier2, no_save_ip, ...). Stress tests against the real upstream sources confirm exact token-count parity (31570 for bytecodes.c, 6438 for optimizer_bytecodes.c).
PLexer wraps Tokenize: cursor, peek / next / backup / expect / require / consume_to. Comments are skipped by default; raw=true exposes them. ConsumeTo tracks paren depth so nested arglists behave.
Stateful AST builder over PLexer producing inst / op / macro / family / pseudo / label declarations. Annotations include the replicate(N) form, stack effects accept the 'name: type [*]' and 'name[size]' shapes, and macro RHS handles 'op + op + cache/N' chains. Block parser handles if / for / while plus the #if/#else/#endif macro form, and rejects switch with the same message upstream uses. Real-source test parses Python/bytecodes.c between BEGIN/END markers without errors.
Most of these are surface tidy-ups: AssignUniqueId / ReleaseUniqueId
take the ID suffix, the uniqueid resize never returned a real
error, the QSBRPad cache-line padding becomes a blank field, and
the cases_generator parameter names drop the trailing underscore
they wore in the Python source. The .golangci.yml carve-outs for
optimizer/analysis.go and tools/uops_gen/{lexer,parsing,plexer}.go
mark the files where parity with the upstream shape is the point.
|
Pushed the lexer + plexer + parsing.py ports today. Token count now matches the Python reference exactly on both upstream sources (31570 for bytecodes.c, 6438 for optimizer_bytecodes.c), so we can rule out silent drift before we start parsing. The recursive-descent shape stays the same as the Python: ifStmt / forStmt / whileStmt / macroIf, contextual stamping via setContext at the end of each successful production, ConsumeTo tracking paren depth so nested arglists do the right thing inside if/for headers. Real-source test parses bytecodes.c between the BEGIN/END markers without any errors, which is what I really wanted before moving on. |
|
A couple of bumps along the way worth flagging:
Token is a value type because the Python source treats it that way, even though gocritic flags every method that takes one. The exemptions in |
|
Status check before I keep going:
The motivation for porting the generator stack instead of hand-translating the generated case bodies is the exact thing that bit me on COMPARE_OP last release: hand-rolled tables drift from upstream the moment someone touches a uop, and you find out at runtime. With the generator in our tree we re-run it whenever bytecodes.c changes and the parity check catches the drift at build time. Lint is at zero on the whole tree, full |
The PushFrame and ReturnValue cases in analysis.go were casting a uint64 operand back to *objects.Function via unsafe.Pointer(uintptr). Go vet flags that as misuse and -race trips checkptr. Today trace.go never writes a real function pointer there (PushFrame ships operand=0, ReturnValue ships a code pointer with low-bit tag), so both paths just early-return. The function-pointer projection lands once we route it through a side table. The globals watcher callback was casting an unsafe.Pointer back to *objects.Dict to call IncrementMutations. Same checkptr issue when a test fires the watcher with a non-Dict pointer for identity. Move the IncrementMutations call to objects.DictMutationHook in WatcherInit where we already have a typed *objects.Dict.
CWriter is the token-aware C emitter that backs the case-body
generators. It tracks indent levels, optionally emits #line directives,
and runs a small spill/reload state machine for the
_PyFrame_{Set,Get}StackPointer pairs the Tier-2 generators need.
Tests cover the spacing, brace indent, StartLine, spill/reload arming
and cancellation, header guards, and the splitlines / isLabel helpers.
The cwriter file joins the lexer/parsing/plexer carve-out for
gocritic/gocyclo/prealloc since Token is held by value to mirror
cwriter.py.
|
CI is green again. Two distinct things were red. The vet job was unhappy about For analysis.go I dropped the round-trip entirely. trace.go currently writes operand=0 for PushFrame and a code-pointer with a low-bit tag for ReturnValue, so both arms were already early-returning before reaching the cast. The function-pointer projection that the upstream remove_globals consumes will land once we route function pointers through a side table rather than a uint64 operand. Until then I left a comment explaining why the case bails out. For the watcher I moved the IncrementMutations call up into objects.DictMutationHook in WatcherInit, where we still have a typed *objects.Dict. The callback itself just invalidates and unsubscribes now, which is what CPython's globals_watcher_callback actually does. Also slipped in cwriter.py while I was here. CWriter is the token-aware C emitter the case-body generators feed into, with the spill/reload state machine for the |
Translates the 1207-line semantic analysis pass into Go: the Properties record (escapes / error_with_pop / uses_locals / has_free / side_exit / oparg / etc.), Skip / Flush / StackItem / CacheEntry / Uop / Label / Instruction / PseudoInstruction / AnalyzedFamily / Analysis records, and the analyzeStack / analyzeCaches / findEscapingAPICalls / checkEscapingCalls / alwaysExits / computeProperties / makeUop / addOp / desugarInst / addMacro / addFamily / addPseudo / addLabel / assignOpcodes / getInstructionSizeForUop walkers. AnalyzeForest is the top-level entry the case-body emitters consume. Tests cover synthetic property classification (DEOPT / ESCAPES / PURE / ERROR_IF), peek / used stack marking, end-to-end analysis of Python/bytecodes.c with sanity checks on LOAD_FAST / _CHECK_VALIDITY / _BINARY_OP_ADD_INT / RESUME_CHECK, the Py_DECREF vs Py_INCREF escape classification, and bad-stack rejection. The Python dump_* debug printers are intentionally not ported; they have no downstream consumer in the case-body emitters.
Translates the 737-line abstract-stack model the case-body emitter walks during render: PointerOffset (signed delta over a fixed base), Local (an SSA-ish slot bound to a StackItem with defined / in_memory / value tracking), Stack (the per-effect grow / shrink helpers, flush_locals, merge / pop / push), and Storage (the higher-level wrapper that holds stack + locals + outputs + spilled flag, plus flush / save / reload / close_inputs / assign_locals / push_outputs). Tests cover PointerOffset arithmetic, OffsetFromItem, the four canonical effect shapes (pure pop, pure push, peek+replace, growing macro), spill / reload bracketing, and a parse -> analyze -> render round-trip that asserts the preamble / postamble bracket the body with the right stack_pointer adjustment.
Translates the 708-line shared rewrite layer the tier1, tier2, and optimizer generators share. Brings over the TokenIterator cursor, writeHeader / rootRelativePath helpers, the Emitter dispatch table (Dispatch / DeoptIf / ExitIf / ErrorIf / ErrorNoPop / DecrefInputs / Kill / KillInputs / SyncSp / StackPointer / SaveStack / ReloadStack / StackrefCloseSpecialized / StackrefSteal / InstructionSize), the _emit_stmt family (simple / macro_if / if / block / for / while), the public EmitTokens entrypoint, and the CFlags formatter. CodeSection is the analyzer-Uop-or-Label union the upstream uses; gopy lifts it to a small interface with adapters. Storage / Stack / Token are reused from analyzer.go and stack.go without redefinition. ROOT becomes a per-call projectRoot parameter so the generator drivers can pass it from a flag. Tests cover TokenIterator next / peek, emitTo paren balancing, alwaysTrue, typeAndNull, CFlags formatting, rootRelativePath, writeHeader, plus per-replacer dispatch goldens (DECREF_INPUTS, SYNC_SP, ERROR_IF conditional and unconditional, ERROR_NO_POP, DEOPT_IF, DISPATCH, stack_pointer spilled-error, INSTRUCTION_SIZE) and an end-to-end EmitTokens run.
Translates the 244-line abstract-interpreter case-table emitter into Go. Reads the main bytecodes.c forest plus the optimizer_bytecodes.c overlay, runs both through AnalyzeForest, and emits per-uop stub handlers preceded by the rewritten C body inside a /* ... */ block. Where optimizer_bytecodes.c overrides a uop, the symbolic body wins. OptimizerEmitter overrides SAVE_STACK / RELOAD_STACK / goto_label; the latter is wired through a new Emitter.GotoLabelHook in generators_common.go so subclasses can swap the JUMP_TO_LABEL default. Storage.Merge had a Go-port bug where the inputs zip indexed past the shorter list when CheckLiveness was off; bounded to the shorter list, matching upstream zip(). Tests cover: output parses as Go, representative symbolic stubs exist (LOAD_FAST, GUARD_TOS_INT, GUARD_NOS_INT, BINARY_OP_ADD_INT, LOAD_CONST_INLINE_BORROW, CHECK_VALIDITY), the LOAD_FAST override from optimizer_bytecodes.c wins over the base DSL definition, and sym_new_const / sym_set_type reach the expected uops.
Translates the 228-line Tier-2 case-table emitter into Go. Output
target is a Go source file (rather than executor_cases.c.h): each
viable Uop emits a /* uop NAME body: */ comment block carrying the
rewritten C body line by line, followed by a func uopNAME() int {
return 0 } stub. The hand-written Tier-2 interpreter loop in
optimizer/uops.go (#431) plugs into the stubs once it lands.
Tier2Emitter overrides DEOPT_IF, EXIT_IF, and oparg on top of the
shared Emitter from generators_common. Iteration is sorted by uop
name for deterministic output.
Tests parse Python/bytecodes.c, run AnalyzeForest, run GenerateTier2,
and assert: representative uops have stubs (LOAD_FAST, RESUME_CHECK,
CHECK_VALIDITY, BINARY_OP_ADD_INT, GUARD_TOS_INT, GUARD_NOS_INT,
EXIT_TRACE), the output parses as Go via go/parser.ParseFile, and
the EXIT_IF replacer emits the Tier-2 JUMP_TO_JUMP_TARGET shape.
Adds BuildUopIDsFromAnalysis and BuildUopMetaFromAnalysis: the analyzer-driven path that mirrors uop_id_generator.py and uop_metadata_generator.py upstream. Both walk analysis.Uops sorted by uop.Name, hard-code _EXIT_TRACE / _SET_IP at IDs 300 / 301 (or 1 / 2 when distinctNamespace), filter Tier-1 uops, and emit the alias form for implicitly-created non-replicated uops. The meta path computes num_popped via the same Stack pop loop the upstream uses against NullCWriter. Cross-check: with 318 IDs and 289 meta rows from Python/bytecodes.c, the analyzer-driven output matches pycore_uop_ids.h and pycore_uop_metadata.h byte-for-byte (modulo trivial map-iteration order, which the sort fixes). The header-parser path stays in place as the drift checker. main.go gains a --from-analysis flag that switches the driver to the new path; the default behavior is unchanged.
On Windows the root path passed to rootRelativePath was being compared against an absolute filename, which filepath.Rel rejects when the two paths sit on different drive prefixes (the unmodified root has no drive, the absoluted filename does). Resolve both paths to absolute before the Rel call so the relative form computes regardless of platform. Fixes the Windows-only failure on TestRootRelativePath_RelativeOk and TestWriteHeader.
|
The cases_generator port is done. Six commits since the last update, walking up the dependency chain. analyzer.go (1207 lines upstream) lands first because everything else feeds off the Properties / Uop / Instruction records it produces. Tests run AnalyzeForest on the real Python/bytecodes.c and assert the per-uop properties for LOAD_FAST, BINARY_OP_ADD_INT, CHECK_VALIDITY, and RESUME_CHECK plus the escape classification of Py_DECREF vs Py_INCREF. stack.go (737 lines) and the abstract stack model: PointerOffset, Local, Stack, Storage, plus the four canonical effect shapes for tests (pure pop, pure push, peek+replace, growing macro), spill/reload bracketing, and a parse-analyze-render round trip. generators_common.go (708 lines) is the shared rewrite layer: TokenIterator, the Emitter dispatch table (DECREF_INPUTS, SYNC_SP, ERROR_IF, DEOPT_IF, EXIT_IF, DISPATCH, etc.), the per-stmt walker, the public EmitTokens entrypoint, and CFlags. Every replacer has a golden snippet test. tier2_generator.go and optimizer_generator.go both emit Go source files. Each viable uop becomes a /* uop NAME body: */ comment block with the rewritten C body line-by-line, followed by a stub func that the hand-written Tier-2 loop will plug into. The optimizer generator overlays optimizer_bytecodes.c on top of the main forest; tests confirm the LOAD_FAST override wins over the base DSL. Last piece: the analyzer-driven id and metadata builders. With 318 IDs and 289 meta rows, the Go output matches Include/internal/pycore_uop_ids.h and pycore_uop_metadata.h byte-for-byte. The header-parser path stays as the drift checker; the new --from-analysis flag is the canonical source going forward. Caught one Windows-only path bug at the end: rootRelativePath was passing the unabsolutized projectRoot to filepath.Rel, which trips when the target path has a drive prefix and the root doesn't. Two-line fix. CI green across linux, macos, windows. |
Both modes parse the bytecodes-flavored DSL (slicing the upstream // BEGIN BYTECODES // / // END BYTECODES // section so the lexer does not trip on the C preprocessor preamble), run the analyzer, and feed the resulting Analysis into GenerateTier2 / GenerateOptimizer. The buffer is run through go/format before it lands on disk so the output is gofmt-clean by construction. Smoke-tested against cpython-314/Python/bytecodes.c (285 viable Tier-2 uops, 7989 lines of generated stubs) and optimizer_bytecodes.c (3995 lines).
The tier2 generator now emits two files instead of one fat stubs file: uops_dispatch_gen.go (executeUop fan-out switch) and uops_stubs_gen.go (StatusDeopt stub per Tier-2-viable uop). Each stub carries the Tier-2-rewritten C body as a // comment block above it so a porter has the spec inline. The generator auto-detects which uop method names *Tier2State already defines elsewhere in optimizer/ (skipping *_gen.go and *_test.go to avoid reading its own output) and omits stubs for that set. To port a uop: write the method on *Tier2State in uops_impl.go, regenerate, and the stub disappears while dispatch keeps routing to the same method name. Future CPython uop additions land as new stubs without touching hand-ported bodies. Hand-ported starter set covers the trace prologue plus the loadfast/storefast/poptop/pushnull/copy/swap quartet so a projected trace can advance past _START_EXECUTOR / _MAKE_WARM / _CHECK_VALIDITY / _SET_IP / _JUMP_TO_TOP / _EXIT_TRACE under real dispatch. The driver loop in uops.go owns the for(;;) over the trace, mirrors enter_tier_two's status branching, and pumps NextUop through jumpToJumpTarget for the JUMP_TO_TOP edge. Smoke tests in optimizer/uops_test.go drive the dispatch loop end to end against the hand-ported set, and a generator round-trip test verifies the ported-detection filter actually drops re-stubbed methods. CLI gains -stubs-out and -ported-from for the cases mode.
Three checks pin the integration between the projector, the analysis pass, and RunExecutor: The hand-ported happy path drives a synthetic trace through every method in uops_impl.go (start, check_validity, set_ip, nop, load_fast, copy, swap, push_null, pop_top, store_fast, load_fast_borrow, make_warm, exit_trace) and asserts the dispatch loop stitched each method into the right slot. Local round-trip plus the warm bit double as witnesses that side effects landed. The stub-bail path runs a trace ending in _BINARY_OP (a stubbed uop as of v0.12) and asserts a clean StatusDeopt without panic, so the fallback story stays runtime-safe while the rest of the hand-port catches up. The end-to-end check feeds an Optimize-produced trace from the loop fixture into RunExecutor and asserts dispatch terminates on either StatusExit or StatusDeopt. Both are valid v0.12 outcomes; the gate just refuses anything else, so a bug that drops dispatch into an infinite loop or a status we never defined fails loudly.
Summary
v0.12.0 ports CPython's Tier-2 optimizer (interpreter-only, no JIT). The
goal is
dis.disparity withENTER_EXECUTORrows and the samesys.monitoring/sys.settraceshape on warmed-up loops, not rawspeed.
What landed
Trace projection + executor lifecycle. New
optimizer/packagewith
_PyExecutorObject,_PyUOpInstruction, the 256-bit bloomfilter, and the side table that hangs off
Code.Executors. Thetwo-phase free uses
InterpState.ExecutorDeletionListHeadso athread inside an executor cannot race
tp_dealloc.Warmup -> install -> dispatch.
JUMP_BACKWARDwarms a per-codeunit counter; on overflow
optimizer.Optimizeprojects a uoptrace, the install path patches the bytecode at the warmup site to
ENTER_EXECUTOR, and subsequent passes land inenterExecutor.Uop dispatch loop.
optimizer/uops.gois the driver; per-uopmethods on
*Tier2StatereturnTier2Status(Continue/Error/Deopt/Exit). 14 uops are hand-ported inuops_impl.go:the trace prologue (
_START_EXECUTOR,_MAKE_WARM,_CHECK_VALIDITY,_SET_IP,_NOP,_EXIT_TRACE,_JUMP_TO_TOP)plus the starter stack/local set (
_LOAD_FAST,_LOAD_FAST_BORROW,_STORE_FAST,_POP_TOP,_PUSH_NULL,_COPY,_SWAP). Theremaining 271 Tier-2-viable uops live as
StatusDeoptstubs in thegenerated
uops_stubs_gen.go. Adding a body removes a stub on thenext regen.
Analysis pipeline.
Analyzeruns the three CPython phases:removeGlobals(stub; the watcher fan-out lands in v0.13),optimizeUops(init/fini bracket, contradiction and out-of-spaceshort-circuits; per-opcode bodies wait on the DSL case table), and
the full port of
removeUnneededUops. The cleanup pass elides_NOProws, prunes_SET_IPahead of non-escaping uops, andcollapses the load-then-pop idiom across the
_LOAD_FAST/_LOAD_CONST_INLINEfamily.DSL generator.
tools/uops_gen/is the parallel totools/bytecodes_gen/, ported fromTools/cases_generator/. Itemits
optimizer/uop_ids_gen.go,uop_meta_gen.go,uops_dispatch_gen.go, anduops_stubs_gen.go. The generatorscans
optimizer/for hand-ported*Tier2Statemethods viago/astand writes only stubs for uops that do not already have amethod, so the dispatch switch and the stub set stay in sync as
ports land. The optimizer-cases pass over
Python/optimizer_bytecodes.cis in but the per-opcode body tableis left to v0.13 (along with the
optimize_uopsper-opcode arms).Prep work bundled in. The COMPARE_OP oparg encoding bug
(
emitCmpOpshipped the bare 0..5 kind; the dispatch reads(oparg >> 5) & 0xf, so1 == 1evaluated as1 < 1and returnedFalse) is fixed at the codegen site to match
codegen.c codegen_addcompare. The lint backlog v0.11 merged withis cleared.
The matching JIT path (
jit.c) stays out of scope. gopy's Tier-2stays interpreter-only.
Gates
v012test/optimizer_gate_test.go. Trace install + theENTER_EXECUTORdeopt path against a tightLOAD_CONST,POP_TOP,JUMP_BACKWARDloop.v012test/analysis_gate_test.go. The cleanup pass runs throughOptimize; the giant-stacksize benign-bail path leaves theinstall site untouched.
v012test/uops_gate_test.go. The dispatch loop drives everyhand-ported uop on a synthetic trace; a stubbed uop returns
StatusDeoptcleanly; a real Optimize-produced trace terminateson
StatusExitorStatusDeopt.optimizer/uops_test.go,optimizer/analysis_test.go. Directunit coverage for the dispatcher and the cleanup pass.
Test plan
go test ./...(full suite)golangci-lint run ./...cleandis.disparity row forENTER_EXECUTOR(covered by theinstall gate)