Three tracks were requested on claude/minipy-tests-performance-187kyu:
- Add the CPython-parity tests that are missing and raise real feature coverage.
- Improve runtime performance at the minipy level, guided by the benchmark corpus.
- 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:
- Named globals get precise declarations.
global.typ is available (compiler/symbol.go:12-16),
so globalTable() becomes the global-side analogue of vmLocals(info).
- 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.Marshaler→Codec, WithMaxHeap→WithHeapLimit,
Cache/Tracer/Coroutine removal, types.Function.LocalKinds→Slots, pass.AddPass→Add.
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 ** -1 → 0.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.
- 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.
- 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.
- 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".
- 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.
- 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().
- 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.
Three tracks were requested on
claude/minipy-tests-performance-187kyu:Baseline is green:
go build ./...,go vet ./...,go test ./...all pass.python3.13(3.13.12) is on PATH, so
TestGoldensMatchCPythonreally runs — all 132 cases pass and goldens canbe 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.mdstill forbid — bare tuple assignment, container==,printing a
dict/set— now work, so those rules are stale. Meanwhile several defects producesilently wrong answers with no diagnostic, which no test pins:
def o(): def i(): return 7; return i()7None10 ** 3010000000000000000000000000000002615903518720002 ** 63922337203685477580803 ** 4012157665459056928801198264970274849The
**results are not CPython's value and not a consistent two's-complement wrap of it(
10 ** 30wrapped is5076944270305263616,3 ** 40is-6289078614652622815), so**issimply wrong rather than divergent-by-design the way
+overflow is.Diagnostics/traps where CPython succeeds:
2 ** -1traps instead of0.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 callargument position, and bare
lambdaoutside a Callable context are all unsupported.Wall-clock baseline (this container, includes process startup)
Root cause behind the perf gap
lowerer.tmp()(compiler/lower.go:268) allocates a module global for every scratchtemporary,
globalTable()(:280) declares all of themvmtypes.TypeRef(boxed), and slots arenever reused.
child()/adopt()(compiler/lower_stmt.go:1527,:1553) keep this true insidefunction bodies too.
emitListIndexNormalize(compiler/lower_expr.go:1158) therefore burns 2 fresh global slots plus2
GLOBAL_SET+ 4GLOBAL_GET+ a branch on every list index. nbody's inner loop has ~20 indexsites; 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
fannkuchstale-read andmatmulwrong-cell defects recorded indocs/benchmarks.md.minivm exposes but minipy never emits
LOCAL_TEE,GLOBAL_TEE,SELECT,BR_TABLE,RETURN_CALL,MAP_GET,STRUCT_NEW, and the entireI32_*arithmetic bank.program.Builder.Locals(...)andtypes.FunctionBuilderlocals both exist.Scope decisions (confirmed)
invariant stands.
wrong; no bigint.
syntax/call gaps.
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 withverify 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):The old verifier had no such case and pushed
KindAny. SinceglobalTable()(
compiler/lower.go:280) declares every slotvmtypes.TypeRef, reading any global back forscalar arithmetic now fails. Scoped by probe:
n = 10/print(n + 1)verify: slot 0, ip 24, i64.add: operand type mismatchxs = [1,2,3]/print(xs[1])verify: slot 0, ip 89, i64.lt_s: operand type mismatchdef f(a: int) -> int: return a + 1print(1 + 2)So named module globals are affected as much as scratch temporaries. Two fixes are required,
and Track C already contains both:
global.typis available (compiler/symbol.go:12-16),so
globalTable()becomes the global-side analogue ofvmLocals(info).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
SELECTform.ARRAY_LENoverwrites the array box without releasingit (
interp/threaded.go), whileARRAY_GETdoes release. ASELECTshaping forcesARRAY_LENon 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 6GLOBAL_*), 1 branch (down from 2), andARRAY_LENonly on thenegative path.
instr.Builder.Trycaptures its depth eagerly, butthe 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 byinstr.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 afunction with >255 named locals already miscompiles silently today.
-O3exception-table corruption, pre-existing.compilation.optimize(
compiler/compiler.go:164-177) restores the pre-optimizationHandlersonto the optimizedprogram, but GVN recomputes handler offsets after changing code length and writes them to
prog.Handlers(transform/gvn.go:47-50). At-O3a program with atryinside code GVNshortened 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 ** intoverflow · #53 scratch slots ·#54
int ** -1· #55str.split()· #56str.format()specs · #57 genexpr in call position ·#58 missing builtins · #59 set operators +
sortedkwargs · #60 divergence cases.#53's body needs updating with the corrected design above, and a new issue is warranted for the
-O3handler-restore finding.Track A — minivm migration (lands together with Track C)
Bump
github.com/siyul-park/minivmtov0.0.0-20260731103021-8b4e647f4b85. The opcode set isbyte-identical between the two versions, so this is mechanical. Five call sites:
program.Verify(p, opts...)program.Verify(p)compiler/compiler.go:129optimize.NewOptimizer(l)optimize.New(l)compiler/compiler.go:169fb.WithLocals(...)fb.Locals(...)compiler/lower_stmt.go:1464,:1499fb.WithCaptures(...)fb.Captures(...)compiler/lower_stmt.go:1500Unused by minipy, so no impact:
interp.Marshaler→Codec,WithMaxHeap→WithHeapLimit,Cache/Tracer/Coroutineremoval,types.Function.LocalKinds→Slots,pass.AddPass→Add.Two things to re-check after the bump, because they are behavioral rather than nominal:
compiler/compiler.go:164-177snapshots and restoresTypes/Handlers/GlobalsaroundOptimize. The newtransformpasses maintainprog.Types(transform/cd.go:92) andprog.Handlers(transform/dce.go:104,transform/gvn.go:49) themselves. Determine whether therestore is now redundant, still needed for
Globals, or actively wrong — and if the workaroundcan 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
pybenchon the new VM —the new interpreter is a generated threaded dispatcher with far more
LOCAL_*superinstructionfusion 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) snapshotstypes.NewCallable(srcTypes(info.params), info.result)intoinfo.local.typwhileinfo.resultisstill the
types.Noneplaceholder set bynestedFuncs(:466-471).checkFunctionBodylaterinfers the real result (
:914-926) but never refreshes the local's callable type, so every call toan unannotated nested function types as
Noneand lowers to a discarded value. Top-level functionsescape this because their calls resolve through
c.functions[key]and readinfo.resultlive.Fix: refresh the declared local's callable type after inference in
functionStmt(
:802-816), beforecheckFunctionDecorators— 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:836andoperator/dynamic.go:672— which also violates theAGENTS.mdinvariant that native operationrules 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, so10 ** 30and+overflow tell the same story. Pin the boundary values as adivergent/case.int ** negative_int. Return a float, as CPython does (2 ** -1→0.5), instead of raisingErrNegativeExponent(operator/host.go:19). This changes the static result type of**when theexponent 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(), andint(s, base). Each needs acallSymbolentry inbuiltins/builtins.go(see the existingpowentry at:54for thespec/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 baseclass'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) andstaticFStringFormat(
compiler/lower_expr.go:588). Routestr.format()through that same parser rather than writing asecond one, then add
,grouping and#alternate form to the shared implementation so bothsurfaces gain them together.
B4. Syntax and call gaps
Generator expressions in call-argument position (
sum(i*i for i in range(5))— currently a parseerror), bare
lambdawithout a Callable context,sorted(key=/reverse=), set operators- | & ^, andstr.partition.B5. Conformance corpus
New categories and cases under
conformance/testdata/conformance/, each with the requiredprovenance 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.minipygolden, and a real anchor indocs/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).
strings/(format specs,partition, no-argsplit),sets/(operators),builtins/(the new constructors,type(),super()).Then rewrite the stale authoring rules in
docs/conformance.md(bare tuple assignment,container
==, printingdict/setare no longer forbidden) and updatedocs/compatibility.mdanddocs/roadmap.mdto 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.
globalTable()(compiler/lower.go:280)stops declaring everything
vmtypes.TypeRefand declares each named global asg.typ.VM()from
global.typ(compiler/symbol.go:12-16) — the global-side analogue ofvmLocals(info).Required by the new verifier, not optional.
tmp()(compiler/lower.go:268)allocates from the current function's local slot space instead of
c.next; emission sites movefrom
GLOBAL_GET/GLOBAL_SETtoLOCAL_GET/LOCAL_SET. The entry frame usesprogram.Builder.Locals(...); function bodies use theFunctionBuilder's locals.The ordering problem to solve:
fb.Locals(vmLocals(info)...)is called before the body islowered (
compiler/lower_stmt.go:1464,:1499), but scratch slots are discovered duringlowering — so the slot list must be finalized after the child lowerer finishes.
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 markof the child's own frame".
LOCAL_GETpushes the slot's declared kind, so a slot reusedacross kinds does not verify. Partition the free list by kind and canonicalize to one
declaration per kind. Erase concrete ref types to
TypeRefdeliberately, so the verifier doesnot start arity-checking calls that go through the indeterminate path today.
emitListIndexNormalizeentirelyfor a provably non-negative index (literal,
len(...), or sums/products of those), and use theone-slot branchy form otherwise — not
SELECT, per the leak above. Also: normalize once inaugAssignSubscript(compiler/lower_stmt.go:856), which currently normalizes the same indextwice, and drop the helper entirely in
assignTargetFromTemp(:770) where the value isalready in a slot.
emitListIndexNormalizeUnderValue(compiler/lower_expr.go:1193) must stayafter the RHS is evaluated — CPython resolves
xs[-1] = rhsagainst the length afterevaluating
rhs, so hoisting it would breakxs[-1] = xs.pop().module.Emitterboundary (module/module.go:98-99). It currently exposesTmp() int, and callers inoperator/emit.goandbuiltins/emit.go,builtins/mapfilter.gothen emit
GLOBAL_GET/GLOBAL_SETagainst that raw index themselves — so the global-vs-localdecision 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.mdmust be regenerated from a realconformance/cmd/pybenchrun, 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 — priorityP0/P1/P2and 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
cca579flanded 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.
fix(compiler): unannotated nested function return type infers as Nonefix(operator): int ** int overflow yields neither CPython's value nor a consistent wrapintPowperf(compiler): allocate scratch temporaries as frame locals instead of module globalscompat: int ** negative_int must return float instead of trappingcompat: str.split() with no separator must collapse whitespace runscompat: str.format() must honor embedded format specs, plus , grouping and # alternate formcompat: parse generator expressions in call argument positioncompat: add list(), tuple(), dict(), set(), type(), super(), int(s, base)compat: support set operators - | & ^test(conformance): pin monkey patching and integer-overflow divergenceschore: migrate to latest minivmEach 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
go test ./compiler ./operator ./builtins,go test ./conformance.go vet ./...andgo test ./....go test ./conformance -run TestGoldensMatchCPython(real python3.13 — must staygreen, and every new
.expectedmust be generated by it, not written by hand).indexing and scratch temporaries in the same frame, plus re-running
fannkuchandmatmul(both currently miscompile) and
nbody(currently fails its correctness gate).go test ./conformance -bench . -benchtime 1x -run '^$'for compile/run cost, andconformance/cmd/pybenchfor the cross-implementation table.*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.Emitterboundary → mechanical migration of the ~74 sites → index-normalization cheapening.A wrong slot type fails
program.Verifyloudly and deterministically, so the mechanical pass isself-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'si,k,jnest has no recursion — say so plainly rather than claiming the fix.Execution policy
Branch.
claude/minipy-tests-performance-187kyuis already atorigin/main(cca579f), so nore-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:
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.Emittercontract — stay here rather than being delegated, since they are the parts aper-step subagent cannot see the whole of.
Step list for the A+C unit
compiler/lower.go(globalTable)go test ./compilerscratchallocator type +slotTypecompiler/lower_scratch.gogo build ./...compiler/lower.go,compiler/lower_stmt.gogo test -run Try ./compilermodule.Emitterslot contractmodule/module.go,compiler/lower.gogo build ./...compiler/lower_expr.go,lower_stmt.go,operator/emit.go,builtins/*go test ./compiler ./operator ./builtinscompiler/lower_expr.go,lower_stmt.gogo test -run ListIndex ./compilerdocs/pybenchThe recursion-aliasing regression test is written first and confirmed red — already done:
walk([1,2,3], 1)returns7instead of24on the pre-refactor tree.