Skip to content

CPython conformance coverage, performance, minivm migration #61

Description

@siyul-park

Three tracks were requested on claude/minipy-tests-performance-187kyu:

  1. Add the CPython-parity tests that are missing and raise real feature coverage.
  2. Improve runtime performance at the minipy level, guided by the benchmark corpus.
  3. Migrate to the latest minivm.

Baseline is green: go build ./..., go vet ./..., go test ./... all pass. python3.13
(3.13.12) is on PATH, so TestGoldensMatchCPython really runs — all 132 cases pass and goldens can
be regenerated with go test ./conformance -run TestGoldensMatchCPython -update.

What probing actually found

The docs undersell the problem in one direction and oversell it in another. Constructs the
authoring rules in docs/conformance.md still forbid — bare tuple assignment, container ==,
printing a dict/set — now work, so those rules are stale. Meanwhile several defects produce
silently wrong answers with no diagnostic, which no test pins:

construct CPython 3.13 minipy
def o(): def i(): return 7; return i() 7 None
10 ** 30 1000000000000000000000000000000 261590351872000
2 ** 63 9223372036854775808 0
3 ** 40 12157665459056928801 198264970274849

The ** results are not CPython's value and not a consistent two's-complement wrap of it
(10 ** 30 wrapped is 5076944270305263616, 3 ** 40 is -6289078614652622815), so ** is
simply wrong rather than divergent-by-design the way + overflow is.

Diagnostics/traps where CPython succeeds: 2 ** -1 traps instead of 0.5; " a b\tc ".split()
gives ['', '', 'a', '', 'b\tc', '']; "{:>8.2f}".format(3.14159) ignores the spec; f"{n:,}"
ignores grouping; list(), tuple(), dict(), set(), type(), super(), int(s, base),
str.partition, set operators - | & ^, sorted(key=/reverse=), generator expressions in call
argument position, and bare lambda outside a Callable context are all unsupported.

Wall-clock baseline (this container, includes process startup)

case CPython 3.13 minipy -O0 minipy -O3
mandelbrot 1933 ms 1997 1884
fib 2578 3476 3524
matmul 1286 2224 2317
binarytrees 1075 2539 2423
nqueens 1152 3586 3733
spectralnorm 1722 3070 3080
sortstress 924 4175 4244
strbuild 1708 7449 7064
nbody 1629 8429 8366

Root cause behind the perf gap

lowerer.tmp() (compiler/lower.go:268) allocates a module global for every scratch
temporary, globalTable() (:280) declares all of them vmtypes.TypeRef (boxed), and slots are
never reused. child()/adopt() (compiler/lower_stmt.go:1527, :1553) keep this true inside
function bodies too.

emitListIndexNormalize (compiler/lower_expr.go:1158) therefore burns 2 fresh global slots plus
2 GLOBAL_SET + 4 GLOBAL_GET + a branch on every list index. nbody's inner loop has ~20 index
sites; that matches its 5.2x gap. The same mechanism is a latent correctness bug: a recursive
function's scratch globals are clobbered by its own recursive call, which is exactly the shape of
the fannkuch stale-read and matmul wrong-cell defects recorded in docs/benchmarks.md.

minivm exposes but minipy never emits LOCAL_TEE, GLOBAL_TEE, SELECT, BR_TABLE,
RETURN_CALL, MAP_GET, STRUCT_NEW, and the entire I32_* arithmetic bank.
program.Builder.Locals(...) and types.FunctionBuilder locals both exist.

Scope decisions (confirmed)

  • Monkey patching: pin as a documented divergence, do not implement. The static subset
    invariant stands.
  • Integer max values: stay 64-bit. Make behavior consistent and pinned rather than silently
    wrong; no bigint.
  • Feature work: all four groups — P0 silent wrong answers, missing builtins, format specs,
    syntax/call gaps.
  • Performance: full scratch-slot refactor.

UPDATE — Track A and Track C are coupled; sequencing corrected

The migration was applied and does not stand alone. The four renames are correct and
go build ./... passes, but ~187 tests fail with verify program: verify: ... operand type mismatch, all on scalar ops.

Cause: the new verifier propagates a global's declared type through GLOBAL_GET
(program/verify.go:483-486):

case instr.GLOBAL_GET:
    t := c.prog.Globals[inst.Operand(0)]
    st.push(slot{kind: t.Kind(), typ: t})

The old verifier had no such case and pushed KindAny. Since globalTable()
(compiler/lower.go:280) declares every slot vmtypes.TypeRef, reading any global back for
scalar arithmetic now fails. Scoped by probe:

program result on new minivm
n = 10 / print(n + 1) verify: slot 0, ip 24, i64.add: operand type mismatch
xs = [1,2,3] / print(xs[1]) verify: slot 0, ip 89, i64.lt_s: operand type mismatch
def f(a: int) -> int: return a + 1 passes — params/locals are already precisely typed
print(1 + 2) passes — no globals

So named module globals are affected as much as scratch temporaries. Two fixes are required,
and Track C already contains both:

  1. Named globals get precise declarations. global.typ is available (compiler/symbol.go:12-16),
    so globalTable() becomes the global-side analogue of vmLocals(info).
  2. Scratch temporaries move to typed frame locals. They are reused across kinds, so they cannot be
    given one precise type while they remain in the global pool — moving them is the fix, not an
    optimization.

Corrected sequencing: Track A and Track C land as one unit. The bump is not committable on its
own. This also settles the open design question below — typed scratch slots are mandatory for
verification, not a performance preference.

Further findings that change Track C's design

  • Reject the branchless SELECT form. ARRAY_LEN overwrites the array box without releasing
    it (interp/threaded.go), while ARRAY_GET does release. A SELECT shaping forces ARRAY_LEN
    on every index, so paired with DUP's retain it would leak one reference per index operation —
    loop-proportional. Use a one-slot branchy form instead: 1 frame slot (down from 2 globals),
    3 LOCAL_* (down from 6 GLOBAL_*), 1 branch (down from 2), and ARRAY_LEN only on the
    negative path.
  • Protected-region depth must be deferred. instr.Builder.Try captures its depth eagerly, but
    the frame's local count is only final after the body is lowered. Buffer the regions during
    lowering and declare them at frame close, where the depth is known.
  • LOCAL_* operands are one byte — 256 slots per frame, silently truncated by instr.New.
    Scoped slot reuse keeps the pool near expression-nesting depth. This also closes a pre-existing
    hole: local.index (compiler/check_decl.go:45) uses the same operand with no bound check, so a
    function with >255 named locals already miscompiles silently today.
  • Possible -O3 exception-table corruption, pre-existing. compilation.optimize
    (compiler/compiler.go:164-177) restores the pre-optimization Handlers onto the optimized
    program, but GVN recomputes handler offsets after changing code length and writes them to
    prog.Handlers (transform/gvn.go:47-50). At -O3 a program with a try inside code GVN
    shortened would get stale offsets. Not reproduced; triage alongside this work since it is the
    same machinery.

Issues filed

#51 nested-function return inference · #52 int ** int overflow · #53 scratch slots ·
#54 int ** -1 · #55 str.split() · #56 str.format() specs · #57 genexpr in call position ·
#58 missing builtins · #59 set operators + sorted kwargs · #60 divergence cases.
#53's body needs updating with the corrected design above, and a new issue is warranted for the
-O3 handler-restore finding.


Track A — minivm migration (lands together with Track C)

Bump github.com/siyul-park/minivm to v0.0.0-20260731103021-8b4e647f4b85. The opcode set is
byte-identical between the two versions, so this is mechanical. Five call sites:

old new site
program.Verify(p, opts...) program.Verify(p) compiler/compiler.go:129
optimize.NewOptimizer(l) optimize.New(l) compiler/compiler.go:169
fb.WithLocals(...) fb.Locals(...) compiler/lower_stmt.go:1464, :1499
fb.WithCaptures(...) fb.Captures(...) compiler/lower_stmt.go:1500

Unused by minipy, so no impact: interp.MarshalerCodec, WithMaxHeapWithHeapLimit,
Cache/Tracer/Coroutine removal, types.Function.LocalKindsSlots, pass.AddPassAdd.

Two things to re-check after the bump, because they are behavioral rather than nominal:

  • compiler/compiler.go:164-177 snapshots and restores Types/Handlers/Globals around
    Optimize. The new transform passes maintain prog.Types (transform/cd.go:92) and
    prog.Handlers (transform/dce.go:104, transform/gvn.go:49) themselves. Determine whether the
    restore is now redundant, still needed for Globals, or actively wrong — and if the workaround
    can go, delete it and update docs/spec/05-codegen.md.
  • trapClasses (compiler/lower.go:67-75) hardcodes minivm's trap message strings. Confirm
    "divide by zero", "index out of range", "type mismatch" still render identically.

The renames are already applied and build clean; what remains is the typed-slot work in Track C
that the new verifier now demands. Once green, re-run the corpus and pybench on the new VM —
the new interpreter is a generated threaded dispatcher with far more LOCAL_* superinstruction
fusion than GLOBAL_*, so it is also the only version on which Track C's win measures honestly.


Track B — correctness and conformance coverage

B1. P0 silent wrong answers (highest priority — these produce wrong values, not errors)

Nested-function return inference. declareFuncLocal (compiler/check_decl.go:51-56) snapshots
types.NewCallable(srcTypes(info.params), info.result) into info.local.typ while info.result is
still the types.None placeholder set by nestedFuncs (:466-471). checkFunctionBody later
infers the real result (:914-926) but never refreshes the local's callable type, so every call to
an unannotated nested function types as None and lowers to a discarded value. Top-level functions
escape this because their calls resolve through c.functions[key] and read info.result live.
Fix: refresh the declared local's callable type after inference in functionStmt
(:802-816), before checkFunctionDecorators — which by its own doc comment validates against
"the function's own (possibly inferred) signature" and is therefore also wrong today.

int ** int. Two duplicate implementations exist — builtins/host.go:836 and
operator/dynamic.go:672 — which also violates the AGENTS.md invariant that native operation
rules live in one place. Consolidate to a single owner, then make the result well-defined: match
the two's-complement wrap that +/-/* already produce via the i64 opcodes, so 10 ** 30 and
+ overflow tell the same story. Pin the boundary values as a divergent/ case.

int ** negative_int. Return a float, as CPython does (2 ** -10.5), instead of raising
ErrNegativeExponent (operator/host.go:19). This changes the static result type of ** when the
exponent is not provably non-negative — coordinate checker and lowerer.

str.split() with no separator. Must collapse runs of whitespace and split on tabs/newlines,
and drop leading/trailing empties.

B2. Missing builtins

list(), tuple(), dict(), set(), type(), super(), and int(s, base). Each needs a
callSymbol entry in builtins/builtins.go (see the existing pow entry at :54 for the
spec/result-type/emit shape), a checker result rule, an emit or host implementation, and tests.
super() is the awkward one: with static dispatch and precomputed class-ID intervals
(computeClassIntervals, check_decl.go:161), super().m() can resolve statically to the base
class's method — implement it as compile-time resolution, not a runtime proxy object.

B3. Format specs

str.format() currently ignores every embedded spec. f-strings already honor width/precision/
alignment via formatSpec (compiler/lower.go:41-49) and staticFStringFormat
(compiler/lower_expr.go:588). Route str.format() through that same parser rather than writing a
second one, then add , grouping and # alternate form to the shared implementation so both
surfaces gain them together.

B4. Syntax and call gaps

Generator expressions in call-argument position (sum(i*i for i in range(5)) — currently a parse
error), bare lambda without a Callable context, sorted(key=/reverse=), set operators
- | & ^, and str.partition.

B5. Conformance corpus

New categories and cases under conformance/testdata/conformance/, each with the required
provenance header and a CPython-generated .expected (never hand-written):

  • divergent/: monkey patching (A.f = g — pin the current refusal), int_pow_overflow,
    int_mul_overflow. Each needs the # minipy-divergence: / # minipy-divergence-doc: pair, a
    .minipy golden, and a real anchor in docs/compatibility.md.
  • ints/: sys.maxsize, boundary arithmetic at ±2^63-1, int(s, base) round-trips.
  • functions/ (new): nested functions with and without annotations, closures over loop variables,
    recursion with scratch temporaries (this doubles as the regression test for Track C).
  • Extensions to strings/ (format specs, partition, no-arg split), sets/ (operators),
    builtins/ (the new constructors, type(), super()).

Then rewrite the stale authoring rules in docs/conformance.md (bare tuple assignment,
container ==, printing dict/set are no longer forbidden) and update
docs/compatibility.md and docs/roadmap.md to match what actually ships.


Track C — performance: scratch slots become frame locals

The single highest-leverage change, and it fixes a correctness bug as a side effect.

  1. Give named module globals precise declared types. globalTable() (compiler/lower.go:280)
    stops declaring everything vmtypes.TypeRef and declares each named global as g.typ.VM()
    from global.typ (compiler/symbol.go:12-16) — the global-side analogue of vmLocals(info).
    Required by the new verifier, not optional.
  2. Replace the global scratch allocator with a frame-local one. tmp() (compiler/lower.go:268)
    allocates from the current function's local slot space instead of c.next; emission sites move
    from GLOBAL_GET/GLOBAL_SET to LOCAL_GET/LOCAL_SET. The entry frame uses
    program.Builder.Locals(...); function bodies use the FunctionBuilder's locals.
    The ordering problem to solve: fb.Locals(vmLocals(info)...) is called before the body is
    lowered (compiler/lower_stmt.go:1464, :1499), but scratch slots are discovered during
    lowering — so the slot list must be finalized after the child lowerer finishes.
  3. Reuse slots. A scoped alloc/release free-list keyed by lifetime (one expression lowering, one
    loop body, one statement) so a function with many index sites does not accumulate hundreds of
    locals. adopt() (:1553) changes from "take the max counter" to "propagate the high-water mark
    of the child's own frame".
  4. Type the slots. Mandatory: LOCAL_GET pushes the slot's declared kind, so a slot reused
    across kinds does not verify. Partition the free list by kind and canonicalize to one
    declaration per kind. Erase concrete ref types to TypeRef deliberately, so the verifier does
    not start arity-checking calls that go through the indeterminate path today.
  5. Stop normalizing indexes that need no normalization. Elide emitListIndexNormalize entirely
    for a provably non-negative index (literal, len(...), or sums/products of those), and use the
    one-slot branchy form otherwise — not SELECT, per the leak above. Also: normalize once in
    augAssignSubscript (compiler/lower_stmt.go:856), which currently normalizes the same index
    twice, and drop the helper entirely in assignTargetFromTemp (:770) where the value is
    already in a slot. emitListIndexNormalizeUnderValue (compiler/lower_expr.go:1193) must stay
    after the RHS is evaluated — CPython resolves xs[-1] = rhs against the length after
    evaluating rhs, so hoisting it would break xs[-1] = xs.pop().
  6. Fix the module.Emitter boundary (module/module.go:98-99). It currently exposes
    Tmp() int, and callers in operator/emit.go and builtins/emit.go, builtins/mapfilter.go
    then emit GLOBAL_GET/GLOBAL_SET against that raw index themselves — so the global-vs-local
    decision leaks across the boundary. Replace it with slot operations that keep the decision inside
    the lowerer (reserve / load / store / release), so native modules never name a storage class.
    This is also what the standard's "phase products do not expose mutable implementation state"
    rule requires.

Re-measure the table above after each step; docs/benchmarks.md must be regenerated from a real
conformance/cmd/pybench run, not edited by hand.


Track D — GitHub issue registration (do first, alongside Track A)

Existing conventions in siyul-park/minipy: Conventional-Commit-style title prefixes
(fix:, compat:, feat:, research:, test:) and two label axes — priority P0/P1/P2
and size SM/MD/LG.

Open issue #49 ("Port CPython test + benchmark suites, fix the bugs they expose, and compare
performance against other Python implementations") is the parent of this work; commit cca579f
landed its waves 0–2. Everything below either continues #49 or is new. Issues that already exist
are not duplicated: #34 (percent formatting), #37 (special-method dispatch), #24 (bigint —
the natural home for the integer-max decision), #30#32, #20.

# Title Labels Source
1 fix(compiler): unannotated nested function return type infers as None P0 SM new — silent wrong answer
2 fix(operator): int ** int overflow yields neither CPython's value nor a consistent wrap P0 SM new — silent wrong answer; also de-duplicates intPow
3 perf(compiler): allocate scratch temporaries as frame locals instead of module globals P0 LG new — perf + recursion aliasing
4 compat: int ** negative_int must return float instead of trapping P0 SM roadmap P0 / #49 B4
5 compat: str.split() with no separator must collapse whitespace runs P0 SM roadmap P0
6 compat: str.format() must honor embedded format specs, plus , grouping and # alternate form P0 MD roadmap P0
7 compat: parse generator expressions in call argument position P1 SM new
8 compat: add list(), tuple(), dict(), set(), type(), super(), int(s, base) P1 MD #49 D7
9 compat: support set operators - | & ^ P1 SM #49 D5
10 test(conformance): pin monkey patching and integer-overflow divergences P1 SM new
11 chore: migrate to latest minivm P1 SM new

Each body states the reproduction (CPython vs minipy output), the owning file and function, the
narrow verification command, and a back-reference to #49. Issues are filed before the
corresponding commits so each commit can close one. Every issue body ends with the Claude Code
attribution footer.

Verification

  • Narrow, per ownership unit: go test ./compiler ./operator ./builtins, go test ./conformance.
  • Full Completion Gate: go vet ./... and go test ./....
  • Differential: go test ./conformance -run TestGoldensMatchCPython (real python3.13 — must stay
    green, and every new .expected must be generated by it, not written by hand).
  • Recursion-aliasing proof for Track C: a conformance case whose recursive function uses list
    indexing and scratch temporaries in the same frame, plus re-running fannkuch and matmul
    (both currently miscompile) and nbody (currently fails its correctness gate).
  • Perf: go test ./conformance -bench . -benchtime 1x -run '^$' for compile/run cost, and
    conformance/cmd/pybench for the cross-implementation table.
  • Tests observe public behavior or the returned *program.Program, never private lowerer state.

Sequencing

Track D (issues #51#60 filed) → Track A + Track C as one commit (the bump does not verify
without typed slots) → Track B1 (silent wrong answers) → Track B2–B4 (feature breadth) →
Track B5 + docs.

Within the A+C unit, the order is: typed named globals → scratch allocator + deferred try depth →
module.Emitter boundary → mechanical migration of the ~74 sites → index-normalization cheapening.
A wrong slot type fails program.Verify loudly and deterministically, so the mechanical pass is
self-checking via go test ./compiler.

Recursion-aliasing regression tests are written before the refactor and confirmed red on the
current tree, so "this is the shape of the fannkuch/matmul defects" becomes a demonstrated fact
rather than a hypothesis. If a benchmark defect turns out not to reproduce as scratch aliasing —
matmul's i,k,j nest has no recursion — say so plainly rather than claiming the fix.

Execution policy

Branch. claude/minipy-tests-performance-187kyu is already at origin/main (cca579f), so no
re-branching is needed. The only uncommitted work is the four minivm renames, which are correct and
stay. Each landed unit gets a focused Conventional Commit; pushes go to that branch.

Delegation. Implementation is delegated to Sonnet subagents, one bounded step at a time, with
this contract for every step:

  • one ownership unit only, with an explicit "do not touch files outside this list" boundary;
  • the owning spec section from the Task Router and the narrow verification command;
  • the subagent runs its narrow tests and reports; it does not commit and does not push;
  • the diff is reviewed here against the Completion Gate before it is committed.

Steps are sequential where they share files, parallel where they do not. The A+C unit is inherently
sequential (each step's verification depends on the previous one); Track B's feature items are
mostly independent and can run two or three at a time.

Cross-phase design decisions — checker/lowerer symmetry, the slot allocator's shape, the
module.Emitter contract — stay here rather than being delegated, since they are the parts a
per-step subagent cannot see the whole of.

Step list for the A+C unit

step scope files narrow check
C0 typed named globals compiler/lower.go (globalTable) go test ./compiler
C1 scratch allocator type + slotType new compiler/lower_scratch.go go build ./...
C2 lowerer wiring; deferred try depth; frame close compiler/lower.go, compiler/lower_stmt.go go test -run Try ./compiler
C3 module.Emitter slot contract module/module.go, compiler/lower.go go build ./...
C4 migrate ~74 emission sites compiler/lower_expr.go, lower_stmt.go, operator/emit.go, builtins/* go test ./compiler ./operator ./builtins
C5 index-normalization elision + one-slot branchy form compiler/lower_expr.go, lower_stmt.go go test -run ListIndex ./compiler
C6 regression tests + benchmark re-measure + docs new test file, docs/ full Gate + pybench

The recursion-aliasing regression test is written first and confirmed red — already done:
walk([1,2,3], 1) returns 7 instead of 24 on the pre-refactor tree.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions