Skip to content

Script-size optimization, Phases 0-2: cost model, EC constant pool, liveness scheduler - #156

Closed
icellan wants to merge 4 commits into
mainfrom
feat/script-size-opt
Closed

Script-size optimization, Phases 0-2: cost model, EC constant pool, liveness scheduler#156
icellan wants to merge 4 commits into
mainfrom
feat/script-size-opt

Conversation

@icellan

@icellan icellan commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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.hex byte-for-byte, script-size-check reports 72/72 ok, and the Go and
Rust cross-compiler golden tests still pass. Both new flags are off unless asked for.

Results

conformance/tests/p256-wallet 958,792 → 304,463 bytes (−68.2 %). That fixture is the
brief's "959,592 B reference implementation" — the number came from Rúnar itself.

variant corpus bytes vs baseline changed grown
current (shipping) 13,526,563
--stack-scheduler=liveness 13,526,482 −0.0 % 34 0
--ec-constant-pool 6,285,154 −53.5 % 9 0
both 6,285,073 −53.5 % 43 0

p384-wallet −76.4 %, ec-primitives −67.4 %, arithmetic 28 → 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
fieldMod at every modular reduction. 20,025 pushes of a 34-byte literal in p256-wallet
alone, 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:

  • Eager dead-slot retirement. 657 of 387,749 OP_PICK/OP_ROLL sites in the corpus are
    deeper 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.
  • That a generic scheduler could reach P-256. 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 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-temp that ran to
completion, 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 through runDifferentialExecution caught it — deployed
script versus the ANF interpreter, on witnesses the repo had already committed to, 2 of 86
cases. Cause: restoring spilled values immediately before an if leaves the parent stack in a
shape lowerIf's arm reconciliation was not written for. Fixed by a precondition (refuse to
spill 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.tsestimateScriptBytes(ops) === emitMethod(...).scriptHex.length / 2
    for every method of every fixture, before and after peephole
  • golden-invariance.test.ts — all 72 goldens reproduced on the default path
  • ec-constant-pool-equivalence.test.ts — OpenSSL P-256/P-384 signatures plus every SEC1
    rejection case (r=0, s=0, r=n, s=n, all-zero sig, wrong message, wrong parity, truncated,
    oversized), on the real @bsv/sdk engine, 44 assertions
  • liveness-scheduler-equivalence.test.ts — the conformance/witnesses/ corpus replayed
    under every variant, 86 cases
  • 4,099 compiler + 1,380 testing + 67 conformance-runner + 125 CLI tests pass

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}, regenerating
9 goldens, re-stamping conformance/script-size-baseline.json (the −67 % shrink trips its
50 % 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:

fixture shipping + pool (this PR) + reduction sinking
p256-wallet 958,792 304,463 179,796 (−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 two are complementary, not independent: without pooling the cheap fieldSub form pushes
the prime twice and p256-wallet gets 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:

  • 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

So brief 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. 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 the
crypto emitters moves ahead of Straus/comb in the ordering.

Commits

  1. feat(compiler) — cost model + size instrumentation (read-only, no byte moves)
  2. feat(codegen) — the two experimental backends, plumbing, and the benchmark runner
  3. docs(experiments) — the three reports

Reproducing

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

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

icellan commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #160, which consolidates this branch with the other two into a single PR. No commits are lost — #160 contains all seven, and the combined measurement is in its description.

@icellan icellan closed this Aug 29, 2026
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