Script-size optimization, Phases 0-2: cost model, EC constant pool, liveness scheduler - #156
Closed
icellan wants to merge 4 commits into
Closed
Script-size optimization, Phases 0-2: cost model, EC constant pool, liveness scheduler#156icellan wants to merge 4 commits into
icellan wants to merge 4 commits into
Conversation
…ntation Nothing in the compiler could compare two candidate lowerings by the metric that actually matters — serialized locking-script bytes. `OP_DUP` and a 33-byte constant push are one instruction each and 34x apart in cost, so an instruction count cannot rank them. - `metrics/cost-model.ts`: `sizeOfStackOp` / `estimateScriptBytes`, routing every push through the same `push-encoding.ts` encoders the emit pass uses. Asserted byte-exact against `emitMethod` for every method of every fixture, before and after peephole — the model is a checked mirror of `06-emit.ts`, not a second opinion about encoding. An unknown opcode throws rather than costing zero. - `metrics/script-metrics.ts`: buckets a serialized script by what each byte is spent on. One rule worth stating: a push immediately consumed by OP_PICK / OP_ROLL is charged to stack access, not to constants, so `bringToTop`'s depth operands do not get blamed on the wrong optimizer. Worth 21,926 bytes of reclassification on p256-wallet alone. - `golden-invariance.test.ts`: every fixture that ships a `.runar.ts` reproduces its checked-in `expected-script.hex`. This is the TS-tier-only version of what the conformance runner checks across seven tiers, so a backend experiment can be shown byte-neutral in seconds without building six native toolchains. Read-only: no pass consults any of this and no emitted byte moves.
…uler
Both are opt-in and inert by default, so the 72 goldens,
conformance/script-size-baseline.json and cross-tier hex parity are untouched.
All 72 fixtures still reproduce their expected-script.hex byte-for-byte, and the
Go and Rust cross-compiler golden tests still pass.
--ec-constant-pool
`fieldMod` pushes the curve's field prime inline at every modular reduction:
20,025 pushes of a 34-byte literal in p256-wallet, 680,850 of its 958,792
bytes. ECTracker gains a pooled slot per constant, and `pushConst` compares the
emitted cost of picking that slot against re-pushing the literal and takes the
cheaper — so no individual call site can grow. Parameterized by CurveParams /
GroupParams, so secp256k1, P-256 and P-384 share one code path.
p256-wallet 958,792 -> 304,463 (-68.2%)
p384-wallet 1,963,300 -> 463,435 (-76.4%)
corpus 13,526,563 -> 6,285,154 (-53.5%), 9 fixtures, none grown
Proved equivalent on the real @bsv/sdk engine against OpenSSL signatures on
both curves, plus every SEC1 rejection case (r=0, s=0, r=n, s=n, all-zero
signature, wrong message, wrong parity, truncated and oversized inputs).
--stack-scheduler=liveness
Parks a result on the alt stack when the next binding does not consume it, so
the operands an ANF chain reads repeatedly stay at depth 0/1; the whole spill
group is restored in production order, which is the order an accumulation
reads it. Plus commutative operand ordering.
Ordering is scored by running the candidate op sequences through the real
peephole rather than a byte formula: two consumed operands at depths 1 and 0
emit OP_SWAP OP_SWAP, which `swap-swap` deletes outright — free — while the
cheaper-looking reversed order emits one real OP_SWAP and costs a byte. That
correction took `arithmetic` from 24 bytes to 18.
arithmetic 28 -> 18 B (-35.7%), the hand-derived optimum
bounded-loop 42 -> 37 B (-11.9%)
~30 mid-size fixtures -0.1%; 34 changed, none grown
Selection is per method: both schedules are lowered and the cheaper one,
measured after peephole, is kept — so "the scheduler never grows a method" is
structural rather than a property of the greedy heuristic.
Spilling is refused in any scope that still has control flow ahead of it.
Restoring immediately before an `if` leaves the parent stack in a shape
lowerIf's arm reconciliation, declared-result trim and Layer B/C depth
invariants were not written for, and it MISCOMPILED if-without-else-multi-temp
into accepting a witness the shipping compiler rejects. Byte counts, the other
goldens and 4,099 compiler unit tests all passed while that was true; the
conformance/witnesses corpus replayed through runDifferentialExecution caught
it, 2 of 86 cases.
Also:
- `conformance/runner/script-metrics.ts` — "where did the bytes go?", the
companion to script-size-check.ts's "did anything grow?". Compares named
compiler variants and never silently drops a fixture from a size report.
- `runDifferentialExecution` accepts both flags, so any experiment can be run
through the same source-vs-script oracle.
- `--stack-scheduler` rejects an unknown mode instead of falling back to the
default: a benchmark that quietly measured the shipping compiler while
reporting an experiment is worse than a crash.
Three reports under docs/experiments/, all reproducible with
`pnpm --filter runar-conformance run script-metrics`.
script-size-optimization-baseline.md
58% of every byte the compiler has ever emitted is a constant push, and 56% of
the whole 13.5 MB corpus is nine numbers — each curve's field prime — pushed
over and over. p256-wallet is 72.7% constant pushes, 71.0% of the fixture in a
single 33-byte literal repeated 20,025 times. Also records that brief Phase 3
(fix-point peephole) and Phase 15 (OP_PUSH_TX binding) already ship.
stack-scheduler-design.md
The current lowering algorithm with line references, the measured
inefficiencies, the byte-cost function, the correctness invariants, and the
benchmark plan — written before the prototype, then updated with what actually
happened, including the miscompile the witness corpus caught and why a passing
bisect can be vacuous.
script-size-optimizer-results.md
What is generic, what is not, and where the remaining bytes are. After
pooling, p256-wallet's 304,463 bytes are 70.6% stack traffic and 27.2%
arithmetic — all of it the modular-reduction sequence itself, ~20,000 times.
Reaching ~30 kB needs the algebra, not more scheduling: modular-domain
analysis first, then Straus/Shamir, then a fixed-base comb.
Two plan assumptions died on measurement and are recorded as dead rather than
quietly dropped:
- Eager dead-slot retirement. 657 of 387,749 pick/roll sites in the corpus are
deeper than 16; typical depths are 2-5, and depths 0-2 are single-byte
opcodes. Every drop would cost bytes to save nothing.
- That a generic scheduler could reach P-256 at all. The crypto emitters build
their own stack layout through ECTracker and never pass through
05-stack-lower.ts — 13.4 MB of the 13.5 MB corpus is out of its reach.
…rojecting it
§3.6 carried an estimate for the next step. It is now a measurement: `fieldMod`
/ `cFieldMod` were patched behind a throwaway switch to emit the short form, the
corpus was re-measured, and the patch was discarded.
p256-wallet 958,792 -> 304,463 (pool) -> 179,796 (+ sinking, -81.2%)
p384-wallet 1,963,300 -> 463,435 -> 272,584 (-86.1%)
ec-primitives 1,332,782 -> 433,880 -> 258,160 (-80.6%)
The sound variant captures 89% of the theoretical floor, so the analysis does
not need to be clever about subtraction. The two optimizations are also not
independent: without pooling the cheap `fieldSub` form pushes the prime twice
and p256-wallet gets LARGER (958,792 -> 999,371). Sinking only pays once the
prime is a 2-byte pick.
The more useful result is what the analysis actually has to prove (new §3.8).
The short-reduction variant passes 256 EC oracle assertions — OpenSSL signatures
on both curves, ec-on-curve-canonicity, ec-degenerate-add, ec-mul-scalars,
p256-p384-scalars, p256-p384-ecdsa-verify. It would have shipped looking green.
It is still unsound:
- multiply / add / mulconst need only `dividend >= 0`, which unsigned
coordinate decoding already gives — ~70% of reductions, trivial analysis;
- subtract needs `subtrahend < p`, which decoding 32 unsigned bytes does NOT
imply.
ecAdd((0, 1), (2^256 - 1, 1))
shipping : ...fffffffdfffff85f
sinking : ...0001000003d0 0x1000003d0 = 2^32 + 977 = 2^256 - p
Reachable only through the unguarded bare builtins; verifyECDSA_* and onCurve
run a canonicity guard first. So Phase 4/5 is a sign lattice plus a `< p` bit
that only subtrahends carry — materially smaller than a full modular-domain
lattice, and the difference between passing 256 oracle assertions and being
correct.
Also reorders §6. At 179,796 bytes the split is 69.6% stack-shuffle / 26.7%
arithmetic and OP_PICK (x36,683) is the largest opcode: once a reduction costs
3 bytes, ECTracker's own operand shuffling is the bottleneck, so the typed
field-element IR moves ahead of Straus/comb.
No code changes; the experiment branch was deleted.
This was referenced Aug 28, 2026
Owner
Author
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.
Phases 0–2 of the script-size optimization brief: baseline instrumentation, an exact
script-byte cost model, and two prototype optimizations behind opt-in flags. No
modular-domain analysis, no witness hints, no scalar-multiplication algorithms yet.
Default output does not move. All 72 fixtures still reproduce their checked-in
expected-script.hexbyte-for-byte,script-size-checkreports 72/72 ok, and the Go andRust cross-compiler golden tests still pass. Both new flags are off unless asked for.
Results
conformance/tests/p256-wallet958,792 → 304,463 bytes (−68.2 %). That fixture is thebrief's "959,592 B reference implementation" — the number came from Rúnar itself.
current(shipping)--stack-scheduler=liveness--ec-constant-poolp384-wallet−76.4 %,ec-primitives−67.4 %,arithmetic28 → 18 B (−35.7 %).What the baseline found
58 % of every byte this compiler has ever emitted is a constant push, and 56 % of the whole
corpus is nine numbers pushed over and over — each curve's field prime, re-pushed by
fieldModat every modular reduction. 20,025 pushes of a 34-byte literal inp256-walletalone, 71 % of the fixture. Pooling removed 99.7 % of that.
Two assumptions in the plan died on measurement, and are recorded as dead rather than quietly
dropped:
OP_PICK/OP_ROLLsites in the corpus aredeeper than 16; typical depths are 2–5, and depths 0–2 are single-byte opcodes anyway. Every
drop would cost 1–3 bytes to save approximately nothing.
layout through
ECTrackerand never pass through05-stack-lower.ts— 13.4 MB of the13.5 MB corpus is out of a generic scheduler's reach. This is the single most important
architectural fact the slice established.
Also confirmed: brief Phase 3 (fix-point peephole) and Phase 15 (OP_PUSH_TX binding) already
ship.
The scheduler miscompiled, and what caught it
The first working scheduler produced a script for
if-without-else-multi-tempthat ran tocompletion, left a truthy top-of-stack, and accepted a witness the shipping compiler
rejects. Byte counts, the goldens for every other fixture, and 4,099 compiler unit tests all
passed while that was true.
conformance/witnesses/replayed throughrunDifferentialExecutioncaught it — deployedscript versus the ANF interpreter, on witnesses the repo had already committed to, 2 of 86
cases. Cause: restoring spilled values immediately before an
ifleaves the parent stack in ashape
lowerIf's arm reconciliation was not written for. Fixed by a precondition (refuse tospill in a scope with control flow ahead) rather than by trying to make the two agree.
Worth flagging for reviewers: my first bisect acquitted spilling, falsely. With commutative
reordering disabled the method-level cost guard simply preferred the baseline schedule, so no
spilling happened at all and the failure vanished for the wrong reason.
Verification
cost-model.test.ts—estimateScriptBytes(ops) === emitMethod(...).scriptHex.length / 2for every method of every fixture, before and after peephole
golden-invariance.test.ts— all 72 goldens reproduced on the default pathec-constant-pool-equivalence.test.ts— OpenSSL P-256/P-384 signatures plus every SEC1rejection case (r=0, s=0, r=n, s=n, all-zero sig, wrong message, wrong parity, truncated,
oversized), on the real
@bsv/sdkengine, 44 assertionsliveness-scheduler-equivalence.test.ts— theconformance/witnesses/corpus replayedunder every variant, 86 cases
Recommendation
The EC constant pool is worth a 7-tier port. Largest single byte win available,
curve-parameterized rather than curve-specific, and proved equivalent against an independent
oracle. Landing it means porting to
compilers/{go,rust,python,ruby,zig,java}, regenerating9 goldens, re-stamping
conformance/script-size-baseline.json(the −67 % shrink trips its50 % guard by design), and adding provenance entries. Not done in this PR — that is why
the flag ships off.
The liveness scheduler should stay experimental. It is correct and never grows a fixture,
but outside small arithmetic contracts it buys 0.1 %. Not worth a 7-tier port; worth keeping
as a gated mode so the next scheduling idea has somewhere to land.
~30 kB is not reachable without the algebra, but the next step is now measured rather than
projected. Patching the reduction emitters behind a throwaway switch, re-measuring, and
discarding the patch gives:
p256-walletp384-walletec-primitivesThe two are complementary, not independent: without pooling the cheap
fieldSubform pushesthe prime twice and
p256-walletgets larger (958,792 → 999,371).The more useful result is what that analysis actually has to prove. The short-reduction
variant passes 256 EC oracle assertions — OpenSSL signatures on both curves,
ec-on-curve-canonicity,ec-degenerate-add,ec-mul-scalars,p256-p384-scalars,p256-p384-ecdsa-verify— and is still unsound:already gives — ~70 % of reductions, trivial analysis;
So brief Phase 4/5 is a sign lattice plus a
< pbit that only subtrahends carry —materially smaller than a full modular-domain lattice, and the difference between passing 256
oracle assertions and being correct. Details in §3.7–3.9 of the results doc.
One consequence worth flagging: at 179,796 bytes the split is 69.6 % stack-shuffle / 26.7 %
arithmetic, and
OP_PICK(×36,683) is the largest opcode. Once a reduction costs 3 bytes,ECTracker's own operand shuffling is the bottleneck — so a typed field-element IR under thecrypto emitters moves ahead of Straus/comb in the ordering.
Commits
feat(compiler)— cost model + size instrumentation (read-only, no byte moves)feat(codegen)— the two experimental backends, plumbing, and the benchmark runnerdocs(experiments)— the three reportsReproducing
pnpm --filter runar-conformance run script-metrics # where the bytes are pnpm --filter runar-conformance run script-metrics -- --fixture p256-wallet --detail pnpm --filter runar-conformance run script-metrics -- --compare current,liveness,ec-pool,both