Compiler optimisations for large contracts (draft — discussion PR) - #428
Draft
mr-zwets wants to merge 12 commits into
Draft
Compiler optimisations for large contracts (draft — discussion PR)#428mr-zwets wants to merge 12 commits into
mr-zwets wants to merge 12 commits into
Conversation
|
@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. |
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: TupleAssignmentTarget gains optional type + isReassignment. - Symbol table: reassignment targets resolve the existing symbol, adopt its type, and register a reference instead of declaring a new variable. The reassignment also counts as the variable's latest use in the final-use bookkeeping (opRolls), so an earlier read can no longer roll the variable off the stack model and crash codegen when the reassignment sits after that read. - Codegen: outside a loop/branch, binding is a pure stack rename; inside one, reassignments are folded in place via emitReplace. The checking layer rejects every invalid form with a proper error: - undefined target -> UndefinedReferenceError - reassigning a constant (local or global) -> ConstantModificationError - reassigning a non-variable symbol (function name) -> InvalidSymbolTypeError - duplicate targets -> DuplicateTupleTargetError - reassignments before declarations -> TupleTargetOrderError, enforced in semantic analysis so statement legality does not depend on whether it sits inside a loop/branch (the codegen check remains as an internal invariant) Adds fixture tests for every rejection and VM execution tests for the scoped emitReplace fold (loop swap, branch fold, a fib-style mixed declaration+reassignment recurrence, and a regression for a scoped reassignment placed after the variable's final plain read). Removes the now-obsolete single_type_destructuring ParseError fixture (mixed typed/untyped targets are valid syntax now). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Purely a compiler-speed change: the optimised bytecode that comes out is
unchanged. Large generated contracts made optimisation slow because the
optimiser round-tripped the whole program through an ASM string and
regex-scanned a growing string for every match; matching opcodes directly
against the Script array makes optimisation ~43x faster on 30k-element
scripts (measured), turning multi-second compiles into tens of
milliseconds.
Mechanics: the ASM-string patterns are pre-parsed once into opcode-number
sequences and matched element-wise. Small-integer pushes are matched
exactly as disassembly would render them (elementOpcode derives
OP_0/OP_1..OP_16/OP_1NEGATE via the same encodeDataPush used for
serialization, so representations cannot diverge). The fixed-point check
compares Script arrays structurally instead of stringifying both scripts
every pass.
Two string-engine behaviours deserve calling out:
- Pass semantics are emulated exactly: regex /g computes matches against
the sweep-start string (a replacement never cascades into another match
within the same rule's sweep), and an empty replacement leaves a double
space that no later single-space pattern can match across until the
pass-end cleanup. The splice engine reproduces both: matches are
collected up front per rule, and removal sites become match barriers for
the remainder of the pass.
- Partial-token safety is preserved: the replaced engine anchored pattern
ends with (\s|$) ("no partial matches"), and element-wise matching is
immune to partial-token matches by construction. (The legacy cashproof
cross-check optimiser, optimiseBytecodeOld, has no such anchor and can
corrupt adversarial scripts — after OP_NOT OP_IF -> OP_NOTIF its rule
`OP_GREATERTHAN OP_NOT` matches inside `OP_GREATERTHAN OP_NOTIF`,
yielding the invalid token OP_LESSTHANOREQUALIF — but that is a separate
engine, unchanged by this commit.)
Both behaviours are locked in by the seeded differential fuzz suite added
in a follow-up commit. Unknown tokens in an optimisation pattern now throw
at load instead of silently never matching.
All debug metadata (source maps, requires, logs, source tags, inline
ranges) is adjusted through the same position-adjustment path as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iler flag Every compile currently optimises twice — once for real, once with the legacy ASM-regex optimiser purely to check that both produce the same bytecode. On very large generated contracts that redundant second pass measurably slows compiles (~0.15s at 10k elements, ~0.5s at 30-40k; near-linear in practice, not quadratic), so the check is now skipped automatically above 30k unoptimised ops and can be skipped explicitly with the new `disableOptimisationCrossCheck` option (e.g. for codegen pipelines that compile many contracts). The flag lives in InternalCompilerOptions (like disableInlining), not the public artifact CompilerOptions: it has zero effect on the produced bytecode, so it is neither public API nor serialized into artifacts. Above the gate, the new optimiser's correctness rests on the differential fuzz suite rather than the per-compile check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng cost The VM charges for every opcode inside a loop on every iteration — including opcodes on branches that don't execute that iteration. Inlining a function body into a loop therefore multiplies its cost by the iteration count, while calling it as a defined function costs just 2 stepped opcodes per iteration at the call site. So functions with a call site inside a loop — directly, or via the callee chain of such a function — are excluded from inlining and stay shared via OP_DEFINE (measured ~2.8x op-cost regression from inlining on sparse-input double-and-add loops, where the body sits in a rarely-taken `if`). A for-loop's init statement runs once, before OP_BEGIN, so a call appearing only there is not loop-resident and stays inlinable. Tiny bodies (<= 2 script elements) stay exempt from the exclusion: inlined they step no more opcodes than the invoke site even when skipped and execute fewer when taken; whether they actually inline is still decided by the byte model. The exclusion is deliberately coarse rather than branch-aware: a callee on a loop-resident caller's always-path costs only ~2 stepped ops per invocation plus ~5 define bytes when kept defined, and the asymmetry (2 ops/iteration sacrificed vs ~100 x body-size/iteration protected) makes conservative the right default even for always-executed single-use loop calls, where inlining would save the 201/call invoke overhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… objective A contract that pushes the same 32-byte constant five times pays for it five times; binding it to a local once and picking it off the stack is much smaller. This commit adds that rewrite, plus the compiler objective that decides when trades like it are worth making. `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. DEFAULT_COMPILER_OPTIONS carries it explicitly so artifacts always record the objective they were compiled under (a default-objective compile stays reproducible from its own recorded options if the default ever changes). Under 'size', an AST pass before semantic analysis binds a raw literal occurring two or more times within one function body to a local, turning later uses into stack picks instead of repeated pushes (~-30 bytes per duplicate of a 32-byte constant, ~+2 ops per execution of the binding). 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. Literal-sensitive positions are excluded from both counting and replacement, because several compiler analyses require a literal in place and replacing it with an identifier would break or silently disable them: split/slice bounds and toPaddedBytes sizes (bounded-type inference and the static IndexOutOfBoundsError), literals compared against .length (bytes narrowing), bit-shift counts, console.log parameters (debug-only data must not inflate real bytecode), and time-op operands (the locktime-guard heuristic keys on them). As defense in depth, the 'size' objective compiles both variants and keeps the hoisted compile only when it is strictly smaller — hoisting can never grow a contract or mask a compile error. - 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 are already deduplicated by the shared-definition mechanism, so only raw literals are targeted - introduced locals borrow source locations from the body (source maps stay valid) and dodge every name already used in the body, including parameter names and tuple targets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… on top) 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). Static bytecode can grow slightly (per-spend cleanup replaces per-call staging); executed op-cost drops, which is what op-bound contracts price. Generation and bitauth-script fixtures updated to the new convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Calling a defined function costs a fixed ~200 op-cost per call before the body even runs, so for tiny bodies the call overhead dwarfs the body itself. Under optimizeFor: 'opcost' (the default), any body of <= 6 bytes is now inlined regardless of use count, even when OP_DEFINE would be smaller by exact byte accounting. CHIP-2021-05 accounting (base 100 per evaluated instruction, +1 per stack-pushed byte): sharing a B-byte body pays 301 + 2B once per spend for the define plus 201 per call for <id> OP_INVOKE, while the body executes at identical cost inlined or invoked — every EXECUTED call site therefore wins op-cost when inlined. Skipped call sites are the exception (an untaken branch steps an inlined body at 100/opcode where an invoke site steps 2 ops), so the rule is a bet on execution density with a small bounded downside per skipped site, not a universal op-cost win. The 6-byte bound is a byte-bloat guardrail, not an op-cost break-even: each inlined call site costs B bytes instead of the ~2-byte invoke site, and any body the byte model would inline at two uses stays inlined at every use count, capping the size regression at ~4 bytes per extra call site (the model omits the body push's length prefix, so the true 2-site break-even is 7 bytes — the cap deliberately stays under it). Locking-side bytes never consume op budget (the budget is derived from unlocking bytecode length), but they still cost fees and count against script size limits, hence the cap. The byte-exact model still governs under optimizeFor: 'size', and the loop-exclusion rule still overrides for loop-resident bodies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…jective The longer the gap between where a variable is defined and where it is first used, the deeper it sits on the stack in between and the more bytes every access pays to dig it out. Moving each definition down its block to just before its first use keeps values near the top of the stack, so contracts compile smaller. Mechanically: a definition compiles to a rename of the value its initializer leaves on top of the stack, so sinking shrinks live ranges — intervening reads get shallower depth arguments and a single-use definition's access pair collapses in the peephole. The size objective compiles both variants and keeps the smaller one, so the pass never costs bytes; the opcost objective skips it (relocating evaluation deepens the initializer's own input reads, and the executed-op balance of that trade is workload-dependent). Sinking can change which require() fails first on an invalid witness and the order of console.log output, never whether a transaction is accepted. Definitions count as assignments in the reorder analysis, so a sunk definition can never move past a later definition of one of its inputs — sinking cannot launder a use-before-definition program into one that compiles. The disableDefSinking escape hatch (for tools that need the source-ordered compile as input) lives in InternalCompilerOptions, like disableInlining, so it is not serialized into artifacts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compiled contracts spend a large share of their bytes fetching values from deep in the stack (`<depth> OP_PICK/OP_ROLL`). This experimental, opt-in pass re-plans each straight-line region's computation order so operands tend to already be on top of the stack when needed, cutting much of that fetch traffic. Measured on the BN254 Groth16 verifier chunks: every chunked family improves 4.9-6.7% in benchmark score (grouped-residue flagship 257,810 -> 241,628; total op-cost 195.4M -> 181.5M), with all proof instances accepted and tampered runs rejected. New `rescheduleStacks` compiler option, applied after bytecode optimisation: split the compiled script into basic blocks at control/verify opcodes, lift each block to a value DAG over its entry stack slots, and re-emit it with a scheduler that computes operands onto the top of the stack instead of fetching every read with a slot access. The pass also chooses each OP_DEFINE'd function's argument-arrival order jointly with its schedule (call sites stage arguments explicitly, so a permuted convention is ~free at the caller; callers and main re-stage accordingly). Candidates — topo / byte-greedy / op-cost-greedy orders plus a small beam search, crossed with entry-order permutations — are ranked by the `optimizeFor` objective; per block the compiler keeps min(original, rescheduled), so no block gets worse. Function bodies are differentially tested on a loosened BCH2026 VM (random input vectors must reproduce the plain compile's output stack) and, under 'opcost', selected by MEASURED op-cost. The main routine relies on the per-block structural guarantee (exact entry->exit layout, boundaries kept verbatim and in order) plus static ranking. Dead computation is never eliminated: a node unreachable from its block's exit (a void guard call's zero-output invoke, an unused variable's failure-capable initializer) can only reject a spend, so re-emitting its block would accept witnesses the plain compile rejects. Such blocks keep their original ops verbatim; variants that cannot (permuted entry or callee conventions) are discarded. Covered by adversarial accept/reject tests on the real BCH2026 VM. Every unexpected condition fails closed to the plain compile: any layout-validation failure falls back to standard argument orders wholesale, a dead-ended beam search throws (callers keep the original body) instead of returning a no-computation schedule, and layout experiments are skipped entirely if any OP_INVOKE is not immediately preceded by its callee-id constant (such a call site would be invisible to the re-staging analysis). Inline debug ranges are dropped (not remapped) across a main rewrite, so debuggers degrade gracefully rather than pointing at wrong instructions. Off by default; single-function contracts only (a selector makes entry stack depth path-dependent). Scripts/bodies with console.log are left untouched; rescheduled regions keep statement-level debug info (merged block source spans, require ips re-anchored to preserved boundaries). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mr-zwets
force-pushed
the
compiler-optimizations-3
branch
from
July 28, 2026 12:11
5baa30d to
47368dc
Compare
The peephole rewrite's guarantee is that swapping string matching for
opcode matching changed nothing about the output. This suite enforces
that guarantee systematically: 2,000 seeded random scripts (plus directed
edge cases and metadata goldens) are optimised by both the new engine and
a string-based reference with the same rule table, and every byte must
agree.
The fuzz earned its keep before landing. It caught a real pass-semantics
divergence in the rewrite, now fixed in that commit: the splice engine
let a replacement cascade into later matches within the same pass, where
regex /g computes matches against the sweep-start string and a removal
leaves an unmatchable double space until the pass-end cleanup — both now
emulated exactly. It also surfaced a latent corruption in the OTHER
string engine still in the tree: the cashproof cross-check optimiser
(optimiseBytecodeOld) builds unanchored regexes, so a rule can match a
token PREFIX (after OP_NOT OP_IF -> OP_NOTIF, its rule
`OP_GREATERTHAN OP_NOT` matches inside `OP_GREATERTHAN OP_NOTIF`,
yielding the invalid token OP_LESSTHANOREQUALIF). The replaced production
engine already anchored pattern ends with (\s|$) ("no partial matches"),
so production was never affected; the reference here mirrors that guard
with \b anchors, and element-wise matching is immune by construction.
Note the in-compiler cross-check compares against optimiseBytecodeOld,
whose cashproof-derived table composes rules in a different order on
adversarial inputs (and lacks the anchor above); the two optimisers are
only expected to agree on compiler-shaped output, which the cross-check
verifies per compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del exact A contract with several functions compiles into one script with a branch per function, and the VM charges 100 op-cost for every opcode it steps over in the branches a spend does NOT take. Inlining a small helper that is called once in each contract function therefore plants copies that every spend pays to walk over — measured ~686 op-cost AND +8 bytes worse than sharing on a 4-function contract. Two changes: - Density guard: a tiny body is only force-inlined under 'opcost' when no spend path can lose — for every entry function, the extra stepping of the other entries' inlined copies (100 x (elements - 2) each vs their 2-op invoke sites) must stay within what inlining saves (the one-time 301 + 2B define plus 201 per own call site). Single-function contracts pass trivially; bodies of <= 2 elements pass at any spread. Call sites inside still-defined helpers are attributed to every entry that reaches them — conservative in the safe direction (keeps OP_DEFINE, forgoing at most the 201/call invoke saving). - Exact byte model: the define site pushes the body as data, which carries a push-length prefix the model previously omitted. With the prefix counted, a 7-byte body used twice is recognised as a byte TIE (14 = 14) and inlines, saving ~700 op-cost for zero bytes; previously it stayed defined. The 6-byte force-inline cap is unchanged: it bounds collateral bytes at ~4 per extra call site (~50 op-cost per byte). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mr-zwets
force-pushed
the
compiler-optimizations-3
branch
from
July 28, 2026 14:20
47368dc to
ba4e309
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Compiler optimisations for large contracts (draft — discussion PR)
Where this comes from
We build Groth16 zero-knowledge verifiers (BN254 and BLS12-381 pairings) as CashScript
contracts — heavy on-stack cryptography at and beyond the practical limits of script size and
the CHIP-2021-05 op-cost budget. Every pass in this PR exists because one of those contracts
needed it, and every headline number below is an applied A/B measurement on those real
contracts, not a synthetic benchmark. The compiler work lives on our fork
(
mr-zwets/cashscript, branchcompiler-optimizations-3, based onnext@ 0.14.0-next.2);the whole series has been through three rounds of adversarial review against the actual diffs,
with empirical probes (VM spend evaluation, differential fuzzing) backing the claims.
The one framing that makes the numbers make sense: two byte axes
"Byte savings" means two different things for these contracts:
size-limited contracts.
zero-padding in their unlocking scripts sized to the op budget, so on the contracts we
actually deploy, ~800 op-cost saved = 1 unlocking byte removed. An "op-cost"
optimisation is a byte optimisation there — through the padding, not the opcodes.
The
optimizeFor: 'size' | 'opcost'objective introduced in this series is exactly this splitmade into a compiler switch.
Disposition table
'opcost'optimizeFor+ constant hoisting'size'pass-O size)'size'-O size)rescheduleStacksEach section below: what it's for in plain terms first, then the measurement, then the
mechanics.
Proposed for merge
Right-to-left argument staging — the standout
What it's for: calling a user-defined function should not cost extra bytes just for putting
the arguments in place. With the current (
next) convention it does: bodies expect the lastparameter on top, so a call passing variables in their natural declaration order pays reversal
ops (
OP_SWAP/OP_ROTchains growing with arity) at every call site.Measured: on the deployed op-bound verifiers, the old convention costs +1.3–2.3% op-cost;
switching conventions saves ~5M op-cost on the chunked build and ~2.9M on the residue flagship —
~6,250 / ~3,600 real unlocking bytes at the 800 op/byte conversion. The single biggest win
in the merge tier, and every contract with user-defined functions benefits to some degree.
Mechanics: compile bodies against a first-parameter-on-top entry layout and stage call
arguments right-to-left. In-order variable arguments then peel from the top at ROLL depths
0,1,2,…(the depth-0 roll optimises away) instead of digging to the bottom at every position —the unavoidable "consume this variable" ROLL doubles as its argument placement. It also removes
an inconsistency: constructor and contract-function parameters already sit first-on-top; the old
convention made user-defined functions the lone exception. Reversed emission breaks the "roll on
last textual use" assumption, so an occurrence guard only ROLLs a name that appears exactly once
in the outermost call's argument tree (fails closed — demoting a roll to a pick is always safe).
Verified with 31 adversarial spend probes on the real VM (shared variables across nested
argument trees, use-after-call, loops/branches, multi-return, lowered constants); the review
found no fixes needed.
This is best understood as undoing a convention regression that shipped with #413 — which is
why we think the right home for it is mainline, so nobody pays it.
Peephole optimiser rewrite + differential fuzz suite
What it's for: purely compiler speed — the optimised bytecode that comes out is unchanged.
Large generated contracts made optimisation slow because the optimiser round-tripped the whole
program through an ASM string and regex-scanned a growing string for every match.
Measured: ~43x faster optimisation on 30k-element scripts; multi-second compiles become
tens of milliseconds.
Mechanics: patterns are pre-parsed once into opcode sequences and matched element-wise
against the Script array; small-integer pushes are matched exactly as disassembly renders them,
via the same encode path used for serialization. All debug metadata flows through the same
position-adjustment path as before.
The fuzz suite is what makes this trustworthy — and it earned its keep before landing: on
its first runs it caught (a) a real pass-semantics divergence in the rewrite (the splice engine
let a replacement cascade into later matches within one pass, where regex
/gcomputes matchesagainst the sweep-start string — now emulated exactly, removal-site barriers included), and (b)
a latent corruption bug in
optimiseBytecodeOld, the cashproof cross-check oracle that isstill in the tree today: its regexes are unanchored, so a rule can match a token prefix
(after
OP_NOT OP_IF → OP_NOTIF, itsOP_GREATERTHAN OP_NOTrule matches insideOP_GREATERTHAN OP_NOTIF, yielding the invalid tokenOP_LESSTHANOREQUALIF). The productionengine this commit replaces was not affected — it already anchored pattern ends with
(\s|$)— and the opcode matcher is immune by construction. Upstream may want to add the sameanchor to
optimiseBytecodeOldindependently of this PR. 2,000 seeded random scripts plusdirected edge cases and metadata goldens now lock the behaviour in.
Cross-check gating
What it's for: every compile currently optimises twice — once for real, once with the
legacy optimiser purely to compare outputs. On very large contracts that redundant second pass
measurably slows compiles (~0.5s at 30–40k elements; near-linear, not quadratic — an earlier
version of this commit claimed O(n²), which review measurement corrected). Skipped
automatically above 30k unoptimised ops, and explicitly via an internal
disableOptimisationCrossCheckoption. Zero effect on output; above the gate, correctnessrests on the fuzz suite.
Loop-resident OP_DEFINE exclusion
What it's for: the VM charges for every opcode inside a loop on every iteration — including
opcodes on branches that don't execute that iteration. So inlining a function body into a loop
multiplies its cost by the iteration count, while a defined function costs 2 stepped opcodes per
iteration at the call site. This pass keeps any function with a call site inside a loop (or
reachable from one via the callee chain)
OP_DEFINE'd.Measured: prevents a ~2.8x op-cost regression on sparse-input double-and-add loops (the
group-law body sits in a rarely-taken
if). Honest framing: this is a guardrail that makesdefault inlining safe to ship, not an independent saving — measured against a plain baseline
it is ~0. A for-loop's init statement runs before
OP_BEGINand is correctly not treated asloop-resident.
6-byte op-cost inlining
What it's for: calling a defined function costs a fixed ~200 op-cost per call before the
body even runs, so for tiny bodies the call overhead dwarfs the body. Under the default
'opcost'objective, bodies of ≤ 6 bytes inline regardless of use count. Every executed callsite wins op-cost when inlined; the 6-byte cap bounds the byte downside to ~4 bytes per extra
call site. Small win, small surface, bounded downside.
Placement-aware inlining (density guard + exact byte model)
What it's for: a contract with several functions compiles into one script with a branch per
function, and the VM charges 100 op-cost for every opcode it steps over in the branches a spend
does not take. So inlining a small helper that is called once in each contract function plants
copies that every spend pays to walk over — measured ~686 op-cost and 8 bytes worse than
sharing, on a 4-function contract. The guard only force-inlines a tiny helper when no spend path
can lose: for every contract function, the stepping cost of the other branches' copies must stay
within what inlining saves (the one-time define plus the invoke overhead). Single-function
contracts are unaffected.
The same commit fixes an off-by-one in the inline-vs-define byte comparison: the define site
pushes the body as data, which carries a push-length prefix the model didn't count. With the
count exact, a 7-byte body used twice is recognised as a byte tie and inlines, saving ~700
op-cost for zero bytes.
For discussion (working and measured, not proposed for merge now)
optimizeFor: 'size' | 'opcost'+ constant hoistingWhat it's for: one compiler objective for every decision that trades bytecode size against
executed op-cost — 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. First
'size'pass: a contract that pushes the same 32-byte constant five times pays for it five times;
binding it to a local and picking it off the stack is much smaller.
Measured: −379 B (−2.6%) on the 14.5 KB BN254 singleton under
-O size(hoisting alone).Note this is ~0 on the deployed chunked verifiers, which run
'opcost'— it's a generalmainline size feature, not a project-score lever.
Safety design (this went through review the hard way): literal-sensitive positions are
excluded from hoisting — split/slice bounds,
toPaddedBytessizes,.lengthcomparisons,bit-shift counts, console parameters, time-op operands — because several compiler analyses
(bounded-type inference, the static
IndexOutOfBoundsError, the locktime-guard heuristic) keyon literals in place, and replacing them would break or silently disable those checks. As
defense in depth, the
'size'objective compiles both variants and keeps the hoisted one onlyif strictly smaller. Why discussion: the objective's API shape (option name, CLI flag,
artifact recording, which future passes it should govern) deserves maintainer input before it
ships and is serialized into artifacts.
Definition sinking (
'size'only)What it's for: the longer the gap between where a variable is defined and where it is first
used, the deeper it sits on the stack in between, and the more bytes every access pays to dig it
out. Sinking each definition to just before its first use keeps values near the top.
Measured: −250 B additional on the singleton. Compiles both variants and keeps the smaller,
so it never costs bytes. Observable effects are limited by design: it can change which
require()fails first on an invalid witness and console.log order, never whether a transactionis accepted (verified: barriers are conservative and a failing initializer can never move past
the block's terminal require). Definitions count as assignments in the reorder analysis, so
sinking cannot launder a use-before-definition program into one that compiles.
rescheduleStacks— opt-in, experimental, explicitly not for merge nowWhat it's for: compiled contracts spend a large share of their bytes fetching values from
deep in the stack (
<depth> OP_PICK/OP_ROLL). This pass re-plans each straight-line region'scomputation order so operands tend to already be on top when needed.
Measured: −37.7% locking bytes on the plain BN254 singleton; −4.9..−6.7% benchmark score on
every deployed chunked family (flagship total op-cost 195.4M → 181.5M); −8..−23% on the vk_x
family. By far the biggest lever in the whole project — and also the biggest surface (a 1,200-line
DAG scheduler).
Why not now: it is off by default, restricted to single-function contracts, and its test
story (a randomized differential fuzz on the real VM, activation-path assertions) is not yet at
the bar we'd want for mainline. What it does already have: per-block
min(original, rescheduled)so no block gets worse; differential testing of function bodies on a loosenedBCH2026 VM; a verified fail-closed design (dead computation is never eliminated — blocks with
exit-unreachable nodes are kept verbatim; any validation failure falls back to the plain
compile wholesale); and adversarial accept/reject tests on the real VM for the soundness cases
review raised. We'd rather show it now and land it later than sit on it.
Related, split out of this PR
unusedmodifier — we built this independently on the fork and have since replaced ourversion with a cherry-pick of Add unused modifier support to cashc #425 (the implementations converged on the same grammar and
symbol design; Add unused modifier support to cashc #425's drop-semantics is a superset). Our use case is padding-byte parameters
that raise the op budget without appearing in logic. One gap worth considering on Add unused modifier support to cashc #425:
allowing
unusedon tuple-destructuring targets, since multi-return forces binding everyvalue — exactly where a discard marker is needed.
(a, b) = f(a, b)) — proposed as its own mergeable PR: small grammardelta, measured −16% on a pairing final-exponentiation and −6% on the verify loop, matches an
open issue. Kept separate so this discussion PR doesn't block it.
Verification story
and measurement docs; the blocking findings (a tuple-reassignment codegen crash, constant
hoisting suppressing a static safety check under
'size') are fixed in the commits as theyappear here, with regression tests.
rescheduling soundness cases.
cashc,@cashscript/utils, and the SDK; the 14.5 KBBN254 verifier compiles in ~1.4s under both objectives.
Happy to share the underlying A/B measurement docs for any number above, split any merge-tier
commit into its own PR, or re-scope the discussion tier however you prefer.
🤖 Generated with Claude Code