Skip to content

Multi-return functions, global constants, and op-cost-aware function inlining#422

Draft
mr-zwets wants to merge 15 commits into
CashScript:nextfrom
mr-zwets:feat/multi-returns
Draft

Multi-return functions, global constants, and op-cost-aware function inlining#422
mr-zwets wants to merge 15 commits into
CashScript:nextfrom
mr-zwets:feat/multi-returns

Conversation

@mr-zwets

@mr-zwets mr-zwets commented Jul 7, 2026

Copy link
Copy Markdown
Member

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

  • Multi-return global functions — functions can declare returns (T, U, ...) and return a, b, ..., destructured at the call site with N-ary tuple assignment (int a, int b = f(...)). Composes with imports; no new keywords.
  • Destructuring into existing variables — tuple targets may be bare identifiers to reassign already-declared variables (a, b = f(x)), enabling in-place updates of loop-carried state.
  • Top-level global constantsint 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).
  • unused modifier 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

  • Function inlining (on by default) — global functions are spliced at call sites instead of shared via OP_DEFINE/OP_INVOKE when 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.
  • Loop-aware exclusion — the VM charges base cost for opcodes even in untaken branches, so functions called inside loops stay OP_DEFINE'd (except tiny bodies, which are strictly cheaper inlined).
  • New 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.
  • Cheaper calling convention — arguments are staged right-to-left (first parameter on top), so a typical call with in-order variable arguments costs zero staging instructions. Measured ~2.9–5M op-cost saved on call-dense groth16 builds.
  • Altstack lowering for function-exit cleanup — past a break-even point, exit cleanup parks return values on the altstack and drops dead locals in OP_2DROP pairs (~1 op per 2 drops instead of 3 per drop).
  • Faster peephole optimiser — matches patterns directly against the script array instead of regex-scanning re-stringified ASM (was quadratic in script size). Output is byte-for-byte identical. Also adds the commutative OP_SWAP OP_MUL rule.
  • Legacy-optimiser cross-check gating — skipped automatically above 10k unoptimised ops, or explicitly via disableOptimisationCrossCheck.

New compiler options

optimizeFor, disableInlining, disableOptimisationCrossCheck (all documented on CompilerOptions).

Notes for reviewers

  • Bytecode for contracts using global functions changes by design (inlining is on by default); tests that assert on the OP_DEFINE/OP_INVOKE shape now pass disableInlining: true.
  • Runtime behaviour is verified via MockNetworkProvider for multi-return ordering, destructuring reassignment in loops, and all inlining shapes (single-use, multi-use, recursion, multi-return).

mr-zwets and others added 13 commits July 7, 2026 12:11
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>
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

@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.

mr-zwets and others added 2 commits July 7, 2026 21:50
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>
@mr-zwets

mr-zwets commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the Compiler optimisations list

the relative importance and the opcost-vs-bytesize axis was missing. The reversed calling convention is the standout, so it
gets its own section; the rest split into ones that change the output and ones that are
compile-time only.

One framing up front: these split across two axes. Some save locking bytecode directly; others
save opcost. For opcost-constrained contracts the two are linked — the unlocking carries
opcost-sized padding, so saved opcost turns into fewer unlocking bytes.

Reversed calling convention

A convention choice, not an algorithm: compile function bodies expecting the first parameter on
top and stage arguments right-to-left. For a call passing variables in declaration order on final
use, each OP_ROLL then peels from the top (depths 0,1,2,…, the depth-0 roll elided) instead of
digging to the bottom every time (depths arity-1), so in-order arguments need no reordering. It's
neutral for ordinary 1–3 arg calls and slightly worse for a reused argument like f(a, a); the win
is real only for call-dense, high-arity bodies (unrolled crypto), where it's material.

Two reasons it belongs in the compiler rather than as caller-side arg reversal (which gets the same
effect): the natural, readable order should be the free one; and a hand-reversal isn't safe once an
argument recurs or is nested, since reversed emission breaks the "roll on last use" assumption. That
safety can't be decided per-argument (it depends on whether a name recurs elsewhere in the tree), so
the commit adds a small occurrence guard that only rolls a name used once — and it fails closed
(demoting a roll to a pick is always safe; only the reverse would corrupt the stack).

It also removes an inconsistency: constructor arguments are baked in reverse declaration order
(generateContractBytecodeScript) and contract-function arguments are seeded the same way, so both
already put the first parameter on top. The old last-parameter-on-top convention made user-defined
functions the lone exception; this aligns them, and lets the body seed through the same
visitParameter path the constructor and spend params use.

Debugging note: because arguments sit in reverse declaration order, at the low level (e.g. BitAuth
IDE) they appear on the stack reversed relative to the signature — first parameter on top, last
deepest. This already holds for constructor and contract-function arguments; the change just makes
user-function calls match.

Output optimisations (size / opcost)

Roughly biggest to smallest for opcost-constrained contracts:

  • optimizeFor (constant hoisting) — a modest but real bytecode-size win, but only under
    -O size; the default 'opcost' objective doesn't run it. A general size feature, not an
    opcost one.
  • Function inlining — small on both axes for typical code (most helpers are single-use or
    loop-resident); mostly about dropping the OP_INVOKE round-trip on non-loop calls rather than
    bytes.
  • Altstack lowering for function-exit cleanup — small, break-even gated; only matters on
    cleanup-heavy exits.
  • Loop-aware exclusion — really a guardrail for inlining rather than a saving of its own: it
    stops inlining from duplicating a body into a loop. Prevents a regression rather than beating
    the baseline.

Compile-time only (output unchanged)

These don't change the emitted script at all — they make the compiler itself viable on large
contracts:

  • Faster peephole optimiser — output is byte-for-byte identical; fixes a quadratic scan that
    hurts large contracts. The new OP_SWAP OP_MUL rule is marginal.
  • Legacy-optimiser cross-check gating — no output change; lets large contracts compile without
    the quadratic cross-check.

So the group is mostly an opcost package rather than a bytesize one, with the calling convention
as the standout and constant-hoisting as the one direct size win (under -O size). Happy to
share the measured A/B numbers behind any of these if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant