Skip to content

Compiler optimisations for large contracts (draft — discussion PR) - #428

Draft
mr-zwets wants to merge 12 commits into
CashScript:nextfrom
mr-zwets:compiler-optimizations-3
Draft

Compiler optimisations for large contracts (draft — discussion PR)#428
mr-zwets wants to merge 12 commits into
CashScript:nextfrom
mr-zwets:compiler-optimizations-3

Conversation

@mr-zwets

Copy link
Copy Markdown
Member

Compiler optimisations for large contracts (draft — discussion PR)

Draft on purpose. This PR shows the full diff of the optimisation work we built for
advanced on-stack cryptography contracts, but we are not proposing to merge all of it now.
Each commit below carries a disposition: a few are proposed for merge as-is, the rest are here
for discussion — shown working, measured, and adversarially reviewed, but better landed
opt-in/experimental or after API discussion. Anchors: the compiler-optimisations list in #422;
the unused modifier overlaps #425 (see notes at the bottom).

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, branch compiler-optimizations-3, based on next @ 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:

  1. Locking bytecode size — the literal compiled script. Relevant for size-scored and
    size-limited contracts.
  2. Op-cost, which converts to bytes on deployable builds — op-bound contracts carry
    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 split
made into a compiler switch.

Disposition table

Commit What it is Axis Headline measurement Disposition
peephole optimiser rewrite compile-speed only, output unchanged compile time ~43x on 30k-element scripts merge
differential fuzz suite locks the rewrite's output-identity claim test found 2 real issues before landing merge
cross-check gating skip the redundant legacy re-optimisation on huge compiles compile time ~0.5s saved at 30–40k elements merge
right-to-left argument staging calling-convention fix op-cost → bytes ~2.9–5M op ≈ 3,600–6,250 unlocking bytes on deployed verifiers merge
loop-resident OP_DEFINE exclusion guardrail for inlining prevents regression prevents a measured ~2.8x op-cost blow-up merge
6-byte op-cost inlining inline tiny bodies under 'opcost' op-cost small; bounded byte downside (~4 B/site) merge
placement-aware inlining don't inline helpers spread across contract functions op-cost fixes a measured −686 op, −8 B regression case merge
optimizeFor + constant hoisting the size/op-cost objective + first 'size' pass bytes (-O size) −379 B (−2.6%) on a 14.5 KB verifier discuss (API shape first)
definition sinking move definitions to first use under 'size' bytes (-O size) −250 B additional on the same verifier discuss
rescheduleStacks DAG re-scheduling of stack computation both −37.7% singleton bytes; −4.9..−6.7% op-cost on deployed chunks not now (opt-in, experimental)

Each 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 last
parameter on top, so a call passing variables in their natural declaration order pays reversal
ops (OP_SWAP/OP_ROT chains 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 /g computes matches
against 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 is
still in the tree today: its regexes are unanchored, so a rule can match a token prefix
(after OP_NOT OP_IF → OP_NOTIF, its OP_GREATERTHAN OP_NOT rule matches inside
OP_GREATERTHAN OP_NOTIF, yielding the invalid token OP_LESSTHANOREQUALIF). The production
engine 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 same
anchor to optimiseBytecodeOld independently of this PR. 2,000 seeded random scripts plus
directed 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
disableOptimisationCrossCheck option. Zero effect on output; above the gate, correctness
rests 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 makes
default 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_BEGIN and is correctly not treated as
loop-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 call
site 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 hoisting

What 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 general
mainline 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, toPaddedBytes sizes, .length comparisons,
bit-shift counts, console parameters, time-op operands — because several compiler analyses
(bounded-type inference, the static IndexOutOfBoundsError, the locktime-guard heuristic) key
on 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 only
if 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 transaction
is 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 now

What 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's
computation 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 loosened
BCH2026 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

  • unused modifier — we built this independently on the fork and have since replaced our
    version 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 unused on tuple-destructuring targets, since multi-return forces binding every
    value — exactly where a discard marker is needed.
  • Tuple reassignment ((a, b) = f(a, b)) — proposed as its own mergeable PR: small grammar
    delta, 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

  • Three rounds of adversarial review against the actual diffs, each claim checked against code
    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 they
    appear here, with regression tests.
  • 2,000-script seeded differential fuzz + directed edge cases for the optimiser rewrite.
  • VM spend probes (accept AND reject directions) for the calling convention, tuple folds, and
    rescheduling soundness cases.
  • Full suite: 940+ tests green across cashc, @cashscript/utils, and the SDK; the 14.5 KB
    BN254 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

@vercel

vercel Bot commented Jul 28, 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 mr-zwets changed the title Compiler optimizations 3 Compiler optimisations for large contracts (draft — discussion PR) Jul 28, 2026
rkalis and others added 10 commits July 28, 2026 12:51
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
mr-zwets force-pushed the compiler-optimizations-3 branch from 5baa30d to 47368dc Compare July 28, 2026 12:11
mr-zwets and others added 2 commits July 28, 2026 16:20
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
mr-zwets force-pushed the compiler-optimizations-3 branch from 47368dc to ba4e309 Compare July 28, 2026 14:20
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.

2 participants