Multi-return functions, global constants, and op-cost-aware function inlining#422
Multi-return functions, global constants, and op-cost-aware function inlining#422mr-zwets wants to merge 15 commits into
Conversation
Extend global functions to declare and return multiple values (`returns (T, U, ...)` / `return a, b, ...`), destructured at the call site via N-ary tuple assignment (`int a, int b = f(...)`). Built on next's location-based function model (FunctionKind.GLOBAL) with no new keyword. - Grammar: N-ary returns clause, N-ary return statement, N-ary tuple destructuring; parser regenerated. - AST: FunctionDefinitionNode.returnTypes[], ReturnNode.expressions[], TupleAssignmentNode.targets[]; Symbol carries returnTypes. - Type checking: return arity/type validation, a multi-return call is only valid as a tuple-destructuring RHS (new ReturnCountError, TupleArityError, MultiReturnDestructureError). - Codegen: return values left on the stack in declared order; cleanStack preserves the top N; call sites push one value per return type. Runtime ordering verified via MockNetworkProvider (quotient/remainder divmod spend), and composition with imports is covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A destructuring target may now be a bare identifier (`x`) to reassign an already-declared variable, alongside fresh declarations (`int x`), in both `a, b = f()` and `(a, b) = f()` forms. Enables in-place update of loop-carried state without the fresh-temp + per-element rebind workaround. - Grammar: tupleTarget (typed declaration or bare identifier); parser regenerated. - AST: TupleTarget interface with optional type + isReassignment. - Symbol table: reassignment targets resolve the existing symbol, adopt its type, and register a reference instead of declaring a new variable (undefined target -> UndefinedReferenceError). - Codegen: outside a loop/branch, binding is a pure stack rename; inside one, reassignments are folded in place (declarations must form a contiguous block below the reassignments). Removes the now-obsolete single_type_destructuring ParseError fixture (mixed typed/untyped targets are valid syntax now). Runtime verified via MockNetworkProvider (loop swap + mixed declaration/reassignment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`unused` marks a variable or parameter as intentionally unreferenced, exempting it from the unused-variable check. Useful for padding-byte parameters that raise the compute budget without being used in logic. - Grammar: `unused` modifier, allowed on parameters (`typeName modifier* Identifier`) and on variable definitions; parser regenerated. - Symbol carries ignoreUnused; unusedSymbols() skips symbols opted out. - SymbolTableTraversal validates modifiers per context (parameters allow only `unused`; variables allow `constant`/`unused`), rejecting duplicate or misplaced modifiers with a new InvalidModifierError. Note: `unused` is now a reserved word (like `constant`); renamed two test fixtures that used it as an identifier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite replaceOps to match optimisation patterns directly against the Script array instead of stringifying to ASM and regex-scanning a growing string on every match. The old approach recovered each match's index via matchAll over the growing prefix, making it O(asm-length) per match — quadratic in script size and pathological for large constant-heavy contracts. Patterns are pre-parsed once and the fixed-point check compares Script arrays structurally. Output is byte-for-byte identical (generation artifact tests unchanged). Cherry-picked from feat/library-support 93d739d. Also add the commutative `OP_SWAP OP_MUL -> OP_MUL` peephole rule (mirroring the existing OP_SWAP OP_ADD). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erhead Global functions are spliced at their call sites instead of being shared via OP_DEFINE/OP_INVOKE when doing so is cheaper by exact byte accounting: defining costs the body once (as a push) plus <id> OP_DEFINE, and <id> OP_INVOKE per call site, while inlining costs the body at every call site. A single-use function always inlines (the DEFINE + INVOKE is pure overhead) and ties favour inlining. An inlined body was compiled with its arguments staged on top of the stack and its cleanup baked in, so splicing it where the args sit runs identically to invoking. Functions are compiled callee-first so an inlined callee is spliced into its callers, while OP_DEFINEs are still emitted in declaration order for stable output. Recursive/cyclic functions are never inlined (they would splice a call to an undefined body) via an invoked-functions guard. Debug info is preserved across inlining: only OP_DEFINE'd functions get a DebugFrame (an inlined body never executes via OP_INVOKE, so its frame would never resolve); instead each call site splices the body's frame-local location data, logs, requires and source tags with ip adjustment. A same-file body keeps its own source locations; a body imported from another file is attributed to the call site (with require/log messages intact), since a source map can only reference its own frame's source file. Inlining is on by default; disable with the new `disableInlining` compiler option (used by the dead-code / import / generation tests that assert on the OP_DEFINE/OP_INVOKE shape). Runtime-verified on the VM: single-use, small multi-use, large multi-use, recursion, and multi-return inlining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng cost The VM charges per-opcode base cost even for opcodes in an untaken branch, so a body inlined into a rarely-taken `if` inside a loop is stepped every iteration (measured ~2.8x op-cost regression on sparse-input double-and-add loops), while a skipped `<id> OP_INVOKE` call site steps only 2 opcodes. Functions with a call site inside a loop -- or reachable from one via the callee chain -- are therefore excluded from inlining and stay shared via OP_DEFINE. Tiny bodies (<= 2 script elements) stay exempt: inlined they step no more than the invoke site even when skipped, execute fewer opcodes when taken, and save the define/invoke bytes -- strictly dominant on every axis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iler flag The backwards-compat cross-check re-optimises every compile with the legacy ASM-regex optimiser, which is O(runs x size^2) (it re-stringifies the whole script per replacement) — on large generated contracts the check dominates compile time. It is now skipped automatically above 10k unoptimised ops, and can be skipped explicitly with the new `disableOptimisationCrossCheck` compiler option (for e.g. codegen pipelines that compile many contracts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`int constant P = <expr>;` at file top level (including imported files, merged through the same flat namespace as global functions). The initializer must be a compile-time constant expression: literals, references to earlier constants, arithmetic/logical/comparison operators, and trivial casts are folded to a single literal; runtime/introspection values are rejected. The folded literal must match the declared type. Constants are inlined at every use site (contract bodies and global function bodies) before semantic analysis, so the rest of the pipeline only ever sees plain literals — this composes with function inlining and keeps the emission op-cost-neutral (no OP_INVOKE per use, no stepping overhead in loops). Locals, parameters, tuple targets, assignments and functions that shadow a constant name are rejected (they would silently break inlining). Ported from the superseded feat/library-support branch, minus the library container syntax. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… objective `optimizeFor: 'size' | 'opcost'` (default 'opcost', CLI: -O / --optimize-for) is the single switch for compiler decisions that trade bytecode size against executed op-cost, and the intended home for future decisions on the same axis (e.g. the function-call convention) — one objective option instead of an accumulating set of booleans that each secretly encode the same question. The objective is a property of the deployment context, not the source, hence an option. Under 'size', an AST pass between constant folding and semantic analysis binds a literal occurring two or more times within one function body to a local, turning later uses into stack picks instead of repeated pushes. This is a byte-size optimisation with an op-cost trade-off (~-30 bytes per duplicate of a 32-byte constant, ~+2 ops per execution of the binding), so the default 'opcost' objective skips it: right for op-bound contracts, whose unlocking scripts are zero-padded to buy op budget — there byte savings are free anyway and the extra ops translate directly into more padding. - exact byte accounting gates each hoist ((count-1) x pushBytes vs pick+cleanup overhead), so small literals are never pessimised - covers int and hex literals; named top-level constants participate naturally since folding has already inlined them to literals by this point - introduced locals borrow source locations from the body (source maps stay valid) and dodge every name already used in the body, including tuple targets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… on top) Restores the cheaper calling convention from the feat/library-support fork. Function bodies are now compiled against a first-parameter-on-top entry layout, and call sites stage arguments right-to-left. With arguments that are variables in declaration order (the overwhelmingly common case), the emitted ROLLs cancel to nothing in the optimiser — a typical call costs zero staging instructions, where the previous last-parameter-on-top convention paid reversal ops (OP_SWAP/OP_ROT chains growing with arity) at every call site. Reversed emission breaks the textual final-use order, so a variable is only ROLLed inside a call argument tree when it appears exactly once across the whole (possibly nested) tree (ArgIdentifierCounter / isOpRoll guard); everything else stays a PICK, with leftovers cleaned once per spend. Measured: on unrolled call-dense bodies (the BN254 lazy tower) the previous convention cost ~2 extra executed instructions per call x thousands of calls (~+2.9M op-cost on the groth16 residue pipeline, ~+5M on groth16-chunked). Output is now byte-identical to the old fork on representative multi-return call patterns. Static bytecode can grow slightly (per-spend cleanup replaces per-call staging); executed op-cost drops, which is what op-bound contracts price. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ationale Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under optimizeFor: 'opcost' (the default), any body of <= 6 bytes is inlined regardless of use count, even when OP_DEFINE would be smaller by exact byte accounting: every invocation costs ~2 executed instructions (funcid push + OP_INVOKE) that splicing a tiny body avoids, and for op-bound contracts the byte cost is free anyway (their unlocking scripts are zero-padded to buy op budget, so extra ops translate directly into more padding while locking bytes do not). The byte-exact model still governs under optimizeFor: 'size', and the loop-exclusion rule still overrides for loop-resident bodies. This restores the old fork's empirically validated blanket small-body inlining (INLINE_MAX_BODY_BYTES = 6); output is now byte-identical to feat/library-support on the previously divergent small-multi-use and nested-tiny-call patterns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cleanStack dropped dead locals one at a time: an OP_NIP chain under a single kept value, or <k> OP_ROLL OP_DROP per item under multi-return values — the ROLL form costs 3 evaluated instructions per drop plus the rolled item's length + depth in metered pushed bytes. Past an exact-accounted break-even on the active objective, the exit now parks the kept values on the altstack (k x OP_TOALTSTACK), drops the rest in pairs (OP_2DROP), and restores them (k x OP_FROMALTSTACK): a fixed 2k-op overhead but ~1 op per 2 drops. A just-pushed constant verification value (a require-style body's OP_1) keeps the NIP path — the peephole already commutes the push past the chain into OP_2DROP pairs, which beats the altstack round-trip by 2 ops. Measured on the groth16 grouped-residue build: -151K worst-case op-cost (212,357,308 -> 212,206,103), -139 B, all 33 chunks still accept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@mr-zwets is attempting to deploy a commit to the Kalis Software Team on Vercel. A member of the Team first needs to authorize it. |
Closes four holes in the destructure-into-existing-variables checking layer, all caught in review: - A multi-return call nested inside the destructuring RHS (e.g. `bytes a, bytes b = pair().split(1)`) compiled and silently discarded return values: the must-be-destructured allowance was a mutable flag consumed by the FIRST call visited in the RHS tree. It is now matched by node identity against the RHS itself. - Reassignment targets skipped the constant check: `int q, f = pair(n)` rebound `int constant f`. Now ConstantModificationError, same as visitAssign. - Reassigning a non-variable symbol (a function name) compiled; now InvalidSymbolTypeError via the standard symbol-class check. - Duplicate targets (`(a, a) = ...`, `int a, a = ...`) compiled with context-dependent winning values; now DuplicateTupleTargetError. The declarations-before-reassignments layout rule moves from a bare codegen Error (no source location, untestable in the fixture harness) into SymbolTableTraversal as TupleTargetOrderError, enforced uniformly so statement legality no longer depends on whether it sits inside a loop or branch; the codegen check remains as an internal invariant. Adds fixture tests for every new rejection and VM execution tests for the scoped emitReplace fold (loop swap, branch fold, and a fib-style mixed declaration+reassignment recurrence), which previously had zero execution coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DEFAULT_COMPILER_OPTIONS now carries optimizeFor: 'opcost' explicitly. The compiler already treated an unset objective as 'opcost' at every decision point, but artifacts serialize the merged options — so a default-objective compile recorded no objective at all, making the artifact non-reproducible from its own recorded options if the default ever changes. Generation fixtures updated accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up on the Compiler optimisations list
|
Multi-return functions, global constants, and op-cost-aware function inlining
This PR builds on the user-defined function support released in v0.14.0-next.1, extending the language with multi-value returns and global constants, and adding a set of compiler optimisations focused on executed op-cost (targeting op-bound contracts like heavy math/ZK workloads).
Language features
returns (T, U, ...)andreturn a, b, ..., destructured at the call site with N-ary tuple assignment (int a, int b = f(...)). Composes with imports; no new keywords.a, b = f(x)), enabling in-place updates of loop-carried state.int constant P = <expr>;at file top level, including in imported files. Initializers are folded at compile time and inlined at every use site, so uses are plain literal pushes (no invoke or loop-stepping overhead).unusedmodifier on variables and parameters — exempts intentionally unreferenced bindings from the unused-variable check (e.g. padding-byte parameters that raise the compute budget).Compiler optimisations
OP_DEFINE/OP_INVOKEwhen cheaper by exact byte accounting. Single-use functions always inline; recursive functions never do. Debug info (source maps, logs, require messages) is spliced per call site, so debugging keeps working.OP_DEFINE'd (except tiny bodies, which are strictly cheaper inlined).optimizeFor: 'size' | 'opcost'objective (default'opcost', CLI-O) — the single switch for size-vs-op-cost trade-offs: under'size'repeated in-body constants are hoisted to locals; under'opcost'small bodies (≤ 6 bytes) are always inlined.OP_2DROPpairs (~1 op per 2 drops instead of 3 per drop).OP_SWAP OP_MULrule.disableOptimisationCrossCheck.New compiler options
optimizeFor,disableInlining,disableOptimisationCrossCheck(all documented onCompilerOptions).Notes for reviewers
OP_DEFINE/OP_INVOKEshape now passdisableInlining: true.