feat(ir,vm): unchecked-add/subtract/multiply as first-class ops - #811
feat(ir,vm): unchecked-add/subtract/multiply as first-class ops#811mparrett wants to merge 2 commits into
Conversation
d41bb3c to
149cf9b
Compare
Review summaryThe IR/opcode/lowering plumbing itself is solid — traced But there's a real correctness bug in the headline claim. The PR says: "The handlers use the same int64 round trip and error wording as CoreUncheckedAdd and friends in pkg/rt/lang.go, so the opcode and the var never disagree." That's not true for any operand that's a Float/BigInt/Boolean/Ratio/BigDecimal:
Confirmed by direct execution: Whether I also traced the same permissive Suggested fix: tighten One more thing worth a sentence in the PR body, not a blocker: the opcode-set signature bump with no new migration entry (bundles from a pre-#811 Requesting changes on the Int-strictness mismatch above — everything else here looks ready to merge once that's resolved. |
nooga
left a comment
There was a problem hiding this comment.
The unchecked-* fast opcode is stricter than the core fn/var it's supposed to mirror (Float/BigInt/Boolean/Ratio silently coerce off the fast path, hard-error on it) — see summary comment for repro and suggested fix.
|
Fixed at the new head, toward the var rather than away from it. Your suggestion went the other way, so here is the reasoning. You were right about the mismatch, and it was worse than the So the opcode now has the same shape as On constfold: with coercion as the runtime contract, folding through the host's Separately: Clojure's On the opcode-set signature bump: agreed that it is the controlled reject-with-error path, and the body's paragraph on it stands as written. |
92ca395 to
d51763f
Compare
unchecked-add, unchecked-subtract and unchecked-multiply reached the IR only as generic calls to the core var, so the Go lowerer emitted a trampoline (or a NativePrimsIntact prim call) and typeinfer learned nothing about the result. Any kernel written on wrapping arithmetic lost the typed native path exactly where it matters. - VM: OP_UNCHECKED_ADD/SUB/MUL, handlers mirror CoreUnchecked* (same int64 round trip and error wording). The bytecode compiler emits them for binary unqualified calls through the existing fast-opcode gate. - IR: catalog rows, builtin-ops entries, an UncheckedOp type that types like the bitwise ops (int/int -> :int, no contagion), bytecode and Go lowering, purity, constfold and commutative membership. int/int lowers to native Go + - * (which already wraps); anything else routes to rt.Unchecked<Op>Value, dispatched on op-kw ahead of the op-str arms so it never falls into the promoting AddValue/SubValue/MulValue. - Arity != 2 falls back to a generic call so the runtime arity error still surfaces (binary-only-ops). Tests: lowering (native + helper path), tier3 opcode semantics (wrap, checked ops still overflow, non-int rejection, shadowing, wrong arity). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… var The OP_UNCHECKED_* handlers asserted Int and hard-errored on anything else, while the core fns coerce Float, Boolean, Ratio and int64-range BigInt through vm.ToInt (the #64 contract). So (unchecked-add 1.5 2) errored as a direct call and returned 3 through apply, and core.lg's unchecked-inc and unchecked-dec, which compile to the opcode, broke on floats. The opcode now has the same shape as OP_ADD: Int/Int inline, everything else through the generic implementation. That implementation moves into pkg/vm as NumUncheckedAdd/Subtract/Multiply and becomes the only one: CoreUnchecked* in lang.go and the AOT Unchecked*Value helpers call it, so the opcode, the var and lowered code cannot drift. The tier3 test now runs each coercing and each rejected input through both the direct call and apply and asserts the results equal; it fails with the opcode forced strict. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
d51763f to
7d82da2
Compare
nooga
left a comment
There was a problem hiding this comment.
@mparrett Nice fix — routing the opcode through the same NumUncheckedAdd/Subtract/Multiply path as the var/AOT cases (instead of just tightening the core fn, which would've silently changed unchecked-inc/dec on floats) closes the gap more thoroughly than what was asked. Ran the full .lg suite plus the new unchecked_arith_test.lg cases — all green, including the exact repro from the review thread via both direct-call and apply. Green light.
Why
unchecked-add,unchecked-subtractandunchecked-multiplyreached the IR only as generic calls to the core var. The Go lowerer had two options for such a call, aCachedVarFntrampoline or theNativePrimsIntactprim call, and typeinfer learned nothing about the result either way. So any kernel written on wrapping arithmetic (a hash, a PRNG, a checksum) lost the typed native path at the operations that make up its inner loop.This surfaced while checking whether a pure let-go xxh3 could be AOT-compiled (nnunley's suggestion on #799). All 52 of its defns lowered, but every
u64+/u64*helper came out as a boxed prim call withvm.Valuein and out, because the arithmetic inside had no IR op to type.What
The three ops become first-class on both backends, shaped like the bitwise ops that already are.
OP_UNCHECKED_ADD/SUB/MUL, appended afterOP_DIV. Each handler has the same shape asOP_ADD: theInt/Intpath inline, and everything else falls back to the generic implementation. That implementation isvm.NumUncheckedAdd/Subtract/Multiply, new inpkg/vm/numbers.go, and it is the only one: the core fns inpkg/rt/lang.goand the AOT helpers inpkg/rt/golower_runtime.gocall it too, so the opcode, the var and the lowered code cannot disagree about what an operand may be. Operands coerce throughvm.ToInt(Float, Boolean, Ratio, an int64-range BigInt), which is the contract feat(core): unchecked-add/subtract/multiply/negate/inc/dec/divide-int — Clojure parity #64 set for the family; only whatToIntrejects is a type error. The bytecode compiler emits the opcodes for binary unqualified calls through the existingtryFastOpcodegate, which already handles shadowing.pkg/ir/ir_ops.lg,builtin-opsentries, anUncheckedOptype that types likeBitwiseOp(only int/int is proven:int; any other operand stays unknown and takes the coercing runtime helper, so no numeric contagion), bytecode and Go lowering, and membership in the purity, constfold, and commutative sets.+-*. Go integer arithmetic already wraps, which is the unchecked contract. Any other operand mix routes tort.UncheckedAddValue/SubValue/MulValue, which coerce and wrap through the samevm.NumUnchecked*. The helper dispatch keys on the op keyword ahead of the op-string arms, because these ops share"+""-""*"with:add:sub:muland must not fall into the promotingAddValuefamily.binary-only-opssends any other arity to a generic call and the runtime arity error still surfaces, instead of the n-ary fold or the(+ x)identity the other builtins get.Generated artifacts (
op_generated.go,core_compiled.lgb, the manifest and sums) are regenerated withmake generate.Adding opcodes changes the opcode-set signature, so bundles built by an older
lgare rejected by this one. That is the same trade #235 made when it addedOP_DIV; no migration entry is added, matching that precedent.What it buys
Measured on a Knuth MMIX LCG step (
unchecked-multiplythenunchecked-add, 2,000,000 iterations, darwin/arm64, three interleaved rounds each):The lowered gain needs the leaf to carry a
^longhint so its params lower toint; the entry point stays untyped so it remains override-eligible.For the pure xxh3 as written (untyped helpers, every call boxed) the whole-hash time did not move, about 75–85 ms per 20,000 hashes either way. There the cost is the boxed call boundary between its fifty small defns, the boundary ABI cost tracked in #722, and this change does not reach it.
Verification
pkg/ir/lisp_lower_go_test.go: int/int emits the native operator and no runtime helper; a boxed operand emits theUnchecked*Valuehelper and never the promoting one.test/core_tier3_test.lgcases pin the opcode against the core fn: wrap atLong/MAX_VALUE, checked+-*still overflow on the same inputs, a shadowing local wins, and a wrong arity reaches the arity error. A parity case runs each coercing input (1.5,2.9,true,1/2) and each rejected one (a string,nil, a BigInt past int64) through both the direct call andapply, so the opcode and the var are asserted equal rather than assumed;unchecked-incandunchecked-decon a float are covered becausecore.lgcomposes them from these ops.make testandmake check-generatedpass; builds for linux/amd64, js/wasm, plan9/amd64, and wasip1/wasm.