Skip to content

hazync#205: hint the pubkey Y and verify it, instead of computing a square root - #206

Closed
defenwycke wants to merge 32 commits into
mainfrom
feat/liftx-hint
Closed

hazync#205: hint the pubkey Y and verify it, instead of computing a square root#206
defenwycke wants to merge 32 commits into
mainfrom
feat/liftx-hint

Conversation

@defenwycke

Copy link
Copy Markdown
Contributor

Closes nothing yet — this is the design plus the guest half of #205.

NOT BUILT AND NOT MEASURED. Draft deliberately. No speedup figure should be quoted from this branch until the A/B below runs.

Why

A profile of block 962,000 (whole block as one chunk, execute mode, RISC0_PPROF_OUT) attributes 1,415,786,221 cycles — 9.83% of the block and ~45% of all post-#139 work — to secp256k1_ge_set_xo_var, the modular square root that recovers a pubkey's Y from its X. patches/0005 does not touch it, because it sits outside secp256k1_ecmult.

Two independent checks that this is real and not an inlining artefact:

check result
op count (~265 field ops/sqrt x ~8,000 keys x ~670 cyc) ~1.42 G predicted vs 1.416 G measured
libsecp bench_internal field_sqrt 6.50 us vs field_mul 0.0246 us = 264x

A measured 264x against an op count of ~265 says the cost is algorithmic, so it carries from native x86 to the guest's rv32im.

What the change does

secp256k1_ge_set_xo_var already computes x3 = x^3 + 7, and already normalises y and flips its sign to the requested parity. The sqrt is the only expensive line. So the hint supplies a root and verification is one extra squaring plus a compare.

Soundness

y^2 == x3 plus the existing parity fixup accepts exactly what the sqrt would have returned — for an x on the curve there are exactly two roots ±y, separated by parity. An x off the curve admits no such y and falls through to libsecp's own sqrt, failing there as before. secp256k1 has prime order and no point of order 2, so y == 0 cannot arise.

Advice-and-verify, not substitution. No group arithmetic is replaced and there is no equivalence surface to argue — libsecp still decides the result. The fidelity posture is strictly better than #139's. A missing or hostile hint costs only the sqrt already being paid.

What is here, and what is not

piece state
patches/0006-lift-x-via-witness-hint.patch written
guest/src/liftx_hint.rs — table, binary search, hazync_lift_x_hint written
HAZYNC_LIFTX_HINT=1 -> liftx-hint feature written
witness carries the hints not written
host extracts pubkeys and computes Y not written
guest calls install() not written
compiled / run not done

docs/LIFTX_HINT.md specifies the remaining host work step by step.

build.rs previously early-returned on the first flag it saw, so it could not express "bigint2 and liftx" — it dropped the second silently. It now accumulates features, which matters because the interesting arm enables both.

The run that settles it

Execute-mode A/B on block 962,000, hinted vs control. No GPU required. The gate is the journal digest — byte-identical to control with all_valid=1, or the change is wrong — plus confirming ge_set_xo_var has collapsed in the profile and hint hits ≈ pubkey count. A run with 0 hits is a failed experiment, not a null result, which is why stats() exists.

Moves METHOD_ID; batch with the other re-baselining changes.

Refs #205.

…e accelerator

A clean branch carrying the middle path alone — no wholesale arm, no
experiment scaffolding — so it can be reviewed and integrated on its own.

It replaces exactly one line of secp256k1_ecdsa_sig_verify, the ecmult
computing u1*G + u2*Q. The r and s zero checks, the modular inversion, both
scalar multiplications, the infinity check and the final r == x(pr) mod n
comparison all stay libsecp's literal code, as do DER parsing, low-S
handling, pubkey parsing and Core's sighash.

Measured on an L40S: 8.00x on GPU proving wall against a stock control of
446 s, from 9.19x in execute-mode cycles — so the separate coprocessor
circuit takes about 13% rather than the whole win. The segment count falls
189 to 21, a 9.0x that corroborates from an independent measurement path, and
the journal digest is identical to stock across all 212 signatures of block
140,000.

Stated rather than glossed: this does not preserve libsecp's wNAF and GLV,
because double_scalar_mul is Shamir's trick. The equivalence surface is far
smaller than substituting the whole ecdsa module but it is not nil, and the
differential testing against chain history that would settle it has not
started.

Gated three times over — optional dependency, cargo feature, #ifdef'd patch —
so a default build is byte-identical and METHOD_ID does not move. Turning it
on does move it, so it must ride one re-baseline with the aggregate witness
read and the Tier 0 batch rather than resetting the board on its own.

Claude-Session: https://claude.ai/code/session_015nyynoX7kXHe7zCF3j6Jbf
…om this doc

Written after a WSL2 VM death lost an uncommitted join-tree implementation. Records
the six-card target and its margins, what each of the four levers is worth, where
the code lives, which of them move METHOD_ID, and the caveats that must ride with
the numbers.
…hen its level does

The level-synchronous barrier in seg_serve_cmd was not inherent. Level l+1 position q
consumes exactly (l, 2q) and (l, 2q+1), so it is ready as soon as those two are. Waiting
for the whole level idles every card that finished early, and the waste grows with the
fleet as the tree narrows: join-tree efficiency is 93% at 2 cards but 40% at 32. That is
why the two-card aggregate test looked healthy -- N=2 is the one regime where the barrier
is invisible.

Ordering is unchanged and that is the point. Tree shape is a function of the leaf count
alone, so it is precomputed: position p pairs with p^1 and the even position is always the
left operand. Joins chain claims and do not commute, so only the schedule changes.
…CMULT window 21

Every arm measured on block 140,000, 212 inputs, execute mode, exact cycle counts
(TIER0_RESULTS_2026-08-26.md). The three independent axes are additive to within
0.001%: C codegen, Rust codegen and the ECMULT table do not overlap.

Window default goes 19 -> 21 rather than the 20 the original combined arm used; 21
was measured later at -1.245%, roughly six times the window-20 change.

C/C++ -flto is deliberately absent -- rust-lld cannot read GCC's LTO bytecode, and
-ffat-lto-objects links without performing any cross-TU optimisation. The hot path is
inside secp256k1.c, which libsecp already builds as a unity TU.

All of this moves METHOD_ID, so it ships in the re-baseline batch, never alone.
…y encoder

HAZYNC_WITNESS_SIZES divided SOURCE byte counts by a WIRE total. Those are different
units and the mismatch is not uniform: risc0 serde spends a 32-bit word per byte for a
plain [u8; 32] or Vec<u8>, so those cost 4x, while PackedBytes costs 1x. Dividing 1x
numerators by a 4x denominator understates exactly the structures that pay the penalty,
which is the question being asked.

Every figure now comes from running the same to_vec the executor uses, per sub-structure,
so they share one unit and the amplification is printed directly. It also splits inputs
into its two WireProofs against everything else, which is the split that decides whether
an encoder is worth writing.

TEN_MINUTE_BLOCK.md 7.8 asked for this before anyone writes an encoder. The 3.13x
per-input and 1.54x validate figures now in the plan are arithmetic over a residual;
this replaces them with a measurement.
…riced 11% at 4x

Block 962,000, measured through the same to_vec the executor uses.

Confirms what 7.8 inferred: inputs is 63.5% (est. ~65%), the two WireProofs are 60.0%
of the whole witness at 3.73x, and 575 wire bytes per input against a 595 B residual.
Confirms txs is 21.6% at 1.03x -- PackedBytes is working and the 2.05x estimate that
assumed otherwise is dead.

Finds one thing nobody had priced: txids is 11.1% of the witness at a full 4.00x, the
worst amplification of any field, and it is absent from the plan and from the ranked
levers. Packing proofs alone is 1.78x on witness bytes; proofs plus txids is 2.09x.

Cycles are inferred, not measured -- roughly 1.52x and 1.69x on block validation. The
plan's 1.54x was about right for the lever it priced. Measure the cycles before quoting
the second one.
…g its removal

The previous 'check whether txids is needed at all' bullet invited the wrong change. It
is the merkle preimage checked against the header AND the binding target every computed
txid is held to -- together what makes 'the raw bytes ARE the block's txs' true, which
the output-leaf reconstruction rests on. Same shape as the tx_prevouts finding in #200.

The win is unaffected: packing changes the encoding, not whether the field is sent, so
every binding is untouched and the 2.09x stands as priced.
…yte path

Measured, not estimated: [u8; 32] and Vec<[u8; 32]> amplify 4.00x through risc0 serde's
default one-word-per-byte path, and the two WireProofs alone were 60.0% of the whole
witness at 3.73x (docs/WITNESS_WIRE_PROFILE_2026-08-28.md).

The measurement also redirected the design. Source is ~73 B per proof -- 32 leaf, 8
position, ~1 sibling -- so at 4x the LEAF is roughly 2.0 MB of the 4.35 MB the proofs
cost. Packing siblings alone would have missed most of it.

PackedHash / PackedHashes mirror the PackedBytes pattern already used for the tx blobs.
Values are byte-identical on both sides; only the encoding changes, so every binding the
guest performs -- the per-tx txid binding, the coinbase binding, the count checks, the
merkle preimage -- is untouched. txids stays on the wire, as it must.

Also drops two flattened copies that existed only to hand a contiguous pointer to C:
Vec<[u8; 32]> is already n*32 contiguous bytes, so merkle_root and the witness commitment
now take a view instead of walking ~200 KB each byte-at-a-time through an iterator chain
and allocating a second copy.

Moves METHOD_ID. Ships in the re-baseline batch. The gate before it counts as working is
an identical journal digest against control.
…te still open

Also records that Tier 0 is provably active -- the build regenerated the ECMULT table from
window 19 to 21 and it grew 38.5 MB to 154 MB -- and that the base tree was already
non-pristine at window 19 before this, which build.rs would not have warned about.
A full build of feat/stack-integration left ecdsa_impl.h at its clean md5, meaning the
stack built without its largest lever and nothing reported it. Enabling it needs BOTH
patch 0005 applied to $HAZYNC_BASE (which is what adds the #ifdef block) AND
HAZYNC_BIGINT2_ECDSA=1 (which defines the macro that block tests). Either alone silently
builds stock libsecp.

The opt-in is deliberate, but a silent default on the 32-to-8-card lever will make anyone
benchmarking this branch measure the wrong thing.
…entical

Tier 0: 32/32 journal digests byte-identical between main and the stack on block 962,000,
all_valid=1 on every chunk, and the two METHOD_IDs differ -- which is what proves both arms
genuinely rebuilt. New result: TIER0_RESULTS validated window 20; window 21 had never had
its digest checked, and -O3 on a -w guest is where UB would surface. None did.

Join tree: both drivers rebuilt over symbolic nodes and compared across 2,052 leaf counts,
identical trees throughout. Joins chain claims and do not commute, so a carry-indexing
error would produce receipts that fail to verify rather than a slower tree.

Both arms gate less than the whole stack, and the doc says which.
Against 2.09x predicted. The shortfall is length prefixes -- every PackedHash carries a
4-byte count and there are 16,012 proofs, ~128 KB the idealised projection ignored.

proofs 3.73x -> 1.11x, txids 4.00x -> 1.00x, per input 575 B -> 193 B. txs is byte-for-byte
unchanged, which is the control: it was already packed, so movement there would have meant
the edit reached something it should not have.

This is a SIZE result, not a correctness one. Host/guest field agreement still needs the
aggregate path and a GPU.
…x ceiling

Three arms on block 962,000, same partitions, one sitting. Tier 0 alone is -2.10% against
2.206% predicted -- additivity fitted on block 140,000 holds on a tip block to within 0.1
points. bigint2 gives 4.384x execute-mode, against 9.19x on block 140,000.

That matters because the stack's '32 -> 8 cards' step uses 8.00x, which is a block-140,000
GPU proving figure, and 140,000 is ~100% ECDSA. risc0-crypto has no BIP340, so taproot
inputs get none of the win; 962,000 predicts 0.88 EC verifies per input. If proving captures
~87% of the execute ratio at tip as it did at 140,000, the step is nearer 32 -> 15.

Inferred, not measured -- execute cycles are not proving cost for a coprocessor circuit.
But it means six cards rests on a figure measured on the most favourable block in the set,
and one GPU prove of a tip chunk would settle it.

Correctness: all 32 digests byte-identical to control with bigint2 on -- a much broader
differential test than 140,000's 212 signatures.
The equivalence check previously lived outside the crate and exercised a transcription of
both algorithms. It now calls the shipped join_tree_widths, with the old level-synchronous
driver transcribed literally beside it -- pop() taking the last element and all -- and
compares the resulting trees across 2,052 leaf counts: every value 1..=2048 plus 116, 501,
1,684 and 8,006.

Pipelining may change WHEN a join runs; it must never change WHICH two receipts meet or in
which order, because join asserts a.post == b.pre and joins therefore do not commute. A
carry-indexing error would produce receipts that fail to verify, not a slower tree.

Second test pins the invariants the schedule relies on: widths start at the leaf count,
terminate at one, and shrink strictly.
A build with HAZYNC_BIGINT2_ECDSA=1 leaves a libsecp256k1.a whose secp256k1_ecdsa_verify
references hazync_ecmult_verify. The next build without the flag drops the Rust side that
exports it but reuses the cached archive, so rust-lld fails on a symbol nothing should be
asking for.

The two traps are symmetric: merging does not enable bigint2 (silent, builds stock libsecp),
and having enabled it, turning it off does not disable it (loud, but the error names a symbol
rather than a cause).

Recovery verified: restoring ecdsa_impl.h changes the C input, cc rebuilds the archive without
the ifdef block, and cargo test --bin host returns 14 passed 0 failed.
…t effect

Two corrections to what this document said.

It is not taproot. BIGINT2_MIDDLE_PATH records block 962,000 as 1.8% taproot by input
(hazync#190 says 2.7% Schnorr), so ~98% of it is ECDSA and does benefit. The 9.19x/8.00x
figures are block 140,000, whose cycles are almost entirely ECDSA.

The real cause is the non-ECDSA residual. Block 962,000 carries ~1.96 G of cycles the
accelerator cannot touch. Redoing the composition over this block's own EC verify count
gives 4.78x predicted against 4.48x measured; the prior session's 7.53x under-weighted
that residual, and the 'chunk card-seconds 14,926 -> 2,278' behind '7 cards' rests on it.

The control cross-checks that session to 0.07% (14.05 G vs 14.06 G), which is what makes
the divergence attributable to the bigint2 arm rather than a different baseline.
Every row of the stack table was a projection. The one row since measured moved 40% --
bigint2 on block 962,000 was projected at 7.53x and measures 4.48x -- so the 5-to-6 cards
at 17-25% margin had no measured support and is removed rather than revised.

The document now leads with a ledger: what was measured and on which block, what was
measured earlier on block 140,000 with a note that it is the most ECDSA-dense block in the
set, and an explicit NOT MEASURED list naming the three runs that would settle the fleet
size. No forecast figures remain in this doc or in WITNESS_WIRE_PROFILE.
The card count is unknown until a PROVING measurement exists: execute mode
measured bigint2 at 4.48x, but bigint2 is a separate coprocessor circuit and
that ratio does not carry over.

Runs stock control and the full four-lever stack on the SAME box, so the ratio
carries no box-to-box variance; run it on both boxes for an independent
replication. po2 22, one prove in flight.

Guards, each for a failure seen in this project:
- asserts the card, since L40S is the reference and wall-clock is not portable
- asserts ecdsa_impl.h md5 per arm, catching BOTH silent bigint2 traps: the
  patch adds the #ifdef, the env var defines the macro, either alone builds
  stock libsecp and says nothing
- restores the clean header and clears riscv-guest between arms, or the cached
  libsecp256k1.a fails to link on hazync_ecmult_verify
- reads METHOD_ID via `host method-id`, never strings, which returns the
  Bitcoin genesis hash; asserts the two arms differ, or neither rebuilt
- captures rc immediately into the log, records peak VRAM, and resumes on
  already-present receipts

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
A hand-rolled `cargo build --release --features cuda` reproduces a trap the
provision script documents: phase 7 is the only place that sets GPU_FEATURES,
so the flag is accepted and silently dropped, and the GPU prover runs at CPU
speed. Nothing fails and nothing warns. Route the build through
HAZYNC_PROVISION=build instead, then assert the CUDA link with ldd rather than
trusting the flag.

Also export what a non-interactive ssh does not: HAZYNC_BASE (how the guest
build.rs finds the Core source), RISC0_HOME, the CUDA paths, and
RECURSION_SRC_PATH for the vendored zkr, whose download has returned 403 and
aborted a build fourteen minutes in.

Add a disk guard. The boxes have 34G free against a script header asking ~80G,
and CUDA 13.2 ships unusable for RISC0 kernels. Reclaim via cargo clean under
18G, refuse to start a build under 10G rather than die mid-way.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
Arm C git-checkouts `main`, and `main` does not contain scripts/gpu-stack-ab.sh, so the
checkout removes the running script from the working tree. The initial run
survives because bash already holds the inode open. A relaunch does not: it
dies instantly with rc 127, "No such file or directory".

That cost a GPU box ~70 minutes of idle time after a hazync#119 retry, because
the relaunch failed in a way that looked like the box was simply still working
-- the monitor saw a live process count and no new FATAL.

Copy to $HOME and re-exec before touching git, so the script's existence never
depends on which branch is checked out.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
⛔ NOT BUILT AND NOT MEASURED. The guest half and the libsecp patch are written;
the witness field, the host-side pubkey extraction and the install() call are
not. No speedup figure should be quoted from this branch.

A profile of block 962,000 (whole block as one chunk, execute mode) attributes
1,415,786,221 cycles -- 9.83% of the block, and ~45% of all post-#139 work --
to secp256k1_ge_set_xo_var, the modular square root recovering a pubkey's Y
from its X. patches/0005 does not touch it: it is outside secp256k1_ecmult.

Two independent checks say the attribution is real rather than an inlining
artefact. An op count gives ~265 field ops per sqrt, predicting ~1.42 G against
1.416 G measured; and libsecp's own bench_internal prices field_sqrt at 6.50 us
against field_mul at 0.0246 us, a ratio of 264x. Agreement to one part in 265
says the cost is algorithmic, so it carries from native x86 to rv32im.

ge_set_xo_var already computes x^3 + 7, and already normalises y and flips its
parity. The sqrt is the only expensive line, so the hint need only supply a
root and the verification is one squaring and a compare.

Soundness: y^2 == x3 plus the existing parity fixup accepts exactly what the
sqrt would have returned, an x off the curve admits no such y and falls through
to the real sqrt, and secp256k1 has no point of order 2 so y == 0 cannot arise.
This is advice-and-verify rather than substitution -- no group arithmetic is
replaced, so unlike #139 there is no equivalence surface to argue.

build.rs previously early-returned on the first flag it saw and so could not
express bigint2 AND liftx, dropping the second silently. It now accumulates.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
hazync#119 is a known intermittent invalid-receipt fault. Box B failed chunk 3
of block 962,000 twice while box A proved the same chunk, from the same binary
and the same METHOD_ID, in 818 s. The two failures took 562 s and 30 s -- the
wildly varying duration on identical input that #119 itself describes. ECC was
clean both times, 0 uncorrected, 31 C.

Dying on it cost a rented box hours of idle time for a fault that is expected
to pass on a retry. Retry up to four times, delete the partial receipt between
attempts so it cannot look banked to the resume check, and if it still will not
prove, record UNPROVEN and CONTINUE.

A block total that is short by one chunk and says so is more useful than a box
that stopped. Every timing line now carries its attempt number so a retried
chunk is never mistaken for a clean first pass.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
The guard cleaned below 18G, but a completed build leaves ~10G free -- so every
relaunch saw 10G, ran cargo clean, and paid a ~19 minute full rebuild for a
disk that was never actually short. Box B paid this twice.

Both boxes built and proved at ~10G throughout, so 18G was simply the wrong
number. Clean below 8G and refuse below 5G.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
Proposes two provers -- Core, where Bitcoin Core decides every consensus
question, and Ghost, free to substitute -- so the fidelity decision is taken
once against numbers rather than re-litigated per lever.

Records three things found while working the idea through.

The classification is three-way, not two. Splitting on "does it touch Core's
source" puts #205 in the wrong bucket: it edits group_impl.h but substitutes
nothing, because libsecp's own arithmetic still checks the hint and returns the
verdict. Advice-and-verify belongs in Core, and it is nearly unexplored --
anything cheaper to verify than to compute is a candidate.

"% of Core" is the share of executed cycles, not of source. "Middle path"
names the surface, not the concession: one line, but the line where ~85% of the
cycles live. Measured on block 962,000 the displaced share is 77.6%, not 85%;
the 85% comes from block 140,000, which has zero taproot and where more of the
field math sits inside ecmult.

And liftx's headline is conditional on #139 -- ~45% of what remains after it,
but only ~10% of the block before it, so ~1.11x in a Core model. The board's
conclusion that the zero-fidelity levers miss the bar survives it.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
⛔ STILL NEVER COMPILED. Both GPU boxes are committed to the A/B and the laptop
has 8 GB, which is not enough for a guest build. No number from this branch.

Host: liftx_hints() walks each transaction's witness items and scriptSig, and
the scripts inside them, for 33-byte compressed and 32-byte x-only keys, then
decompresses with the bitcoin crate and returns (x, y) sorted by x -- which is
the order the guest's binary search wants. Only the even root is stored, since
ge_set_xo_var flips the sign itself to match the requested parity, so one entry
per x serves both callers.

Completeness is deliberately approximate: a key not found pays the sqrt it pays
today, and a key found WRONG fails the guest's y^2 == x^3 + 7 check and also
falls back. The table is never trusted, so it never has to be right.

The block is written before the groups and read before the read loop, because
the guest verifies scripts as it reads them -- the table has to be installed
before the first VerifyScript call, not after the last read.

The guest logs hits and misses. An empty table would reinstate the sqrt while
every gate still passed, so a run with zero hits has to read as a failed
experiment rather than a null result.

⚠ HAZYNC_LIFTX_HINT must hold the same value at build time and at run time: the
guest only reads the block when built with the feature, so a mismatch
desynchronises the stream rather than merely losing the optimisation.

Refs #205.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
defenwycke added a commit that referenced this pull request Aug 31, 2026
…11): Core is 9 cards

                                       speedup  chunk  strag  cards
    this morning (assumed straggler)    3.836x  3758s  1.054      8
    ...with the straggler MEASURED      3.836x  3758s  1.557     11
    + liftx hint                        4.095x  3521s  1.557     11
    + refitted packing constants        4.095x  3521s  1.311      9

## The liftx hint, #205/#206, finally measured

+6.31%, digest 4fb3e3c5...4656d PASS, 6,897 hits / 134 misses (98.1%).

Predicted <=6.17% from the CUMULATIVE profile; measured 6.31%. Its FLAT self-time is
0.30%, so a flat profile hides this lever 20x -- the same shape that made me
under-size the zero-copy fix by 3.3x this morning. Size delegating functions
cumulatively.

⛔ #206's patch was MALFORMED and had never been applied: it claimed
@@ -316,12 +316,38 @@ where the truth is @@ -323,7 +323,36 @@ -- wrong line number
AND both counts. That is why it never measured; nothing had ever tried. Regenerated
with `diff`. Renumbered 0006 -> 0013 (0006 is the Schnorr patch here).

⛔ Its cited figures were stale by ~6x: 1.416 G / 9.83% was measured with SOFTWARE
field arithmetic. With patches/0012 the same chain is 241 M / 6.17%. Corrected in the
patch header and the module.

⛔ EIGHTH silent no-op of the session: the first hint build exported the Rust symbol
and never referenced it, because nothing defined -DHAZYNC_LIFTX_HINT for the secp
build. Only the cargo feature was wired. The binary assertion caught it; without that
it would have reported a moved METHOD_ID and a clean digest and measured zero.

## The straggler was being measured against itself

The reported straggler was computed on PREDICTED cost. The cost packer balances its own
predictor by construction, so it reported a perfect 1.00x while real chunk cycles spanned
33.6 M to 352 M. A check that cannot fail.

Now reported on MEASURED cycles whenever HAZYNC_PROFILE_EXEC gives them, with a
cost-model error line naming the worst chunk's actual-vs-predicted share.

## The cost model was calibrated for a different build

COST_PER_EC_OP=141,612 prices an ECDSA verify that #139 has accelerated, and
COST_PER_SCHNORR_OP is 13.8x higher because #139 does not touch Schnorr. Core has no
#139: the field backend accelerates both curves equally, so both the split and the
scale are wrong. Least squares over 32 chunk executions:

    cycles = 450,020*ec + 1.27*bytes + 37,946*inputs + 3,292,850   mean |error| 5.0%

against defaults of 141,612 / 6 / 34,000 -- EC 3.2x too low, bytes 4.7x too high.

The constants are now runtime-overridable (HAZYNC_COST_EC_OP etc.), defaults unchanged,
so Ghost's calibration is untouched. Host-side only; nothing reaches the guest.

MEASURED with the refit: straggler 1.557 -> 1.311, chunk spread 10.4x -> 1.5x.

⚠ It did NOT reach the ~1.05 I hoped for. The fit is on chunk AGGREGATES while the
packer applies costs PER INPUT, and I flagged that risk before measuring. The residual
1.5x spread now looks like granularity rather than mis-pricing, which points at more
chunks -- the aggregate is independent of chunk count, so fan-out is close to free.

Claude-Session: https://claude.ai/code/session_017fQ8BAyHyzxBf3bvdmckVL
@defenwycke

Copy link
Copy Markdown
Contributor Author

The work here landed on feat/liftx-accel in 0f90ed0, with three fixes it needed first — closing this in favour of that.

⛔ The patch was malformed and had never been applied

patches/0006-lift-x-via-witness-hint.patch claims:

@@ -316,12 +316,38 @@

The truth is @@ -323,7 +323,36 @@wrong line number and both counts, so patch rejects it outright with malformed patch at line 63. That is why this never produced a number: nothing had ever tried to apply it. Regenerated with diff rather than by hand.

⛔ The cited figures overstated the lever ~6x

The patch header and liftx_hint.rs both cite fe_sqrt at 1.416 G cycles / 9.83%. That was measured with libsecp's software field arithmetic. With patches/0012's coprocessor field backend, every multiply and square in the sqrt chain is already ~14x cheaper, so re-measured on the same block it is 241,023,621 cycles cumulative = 6.17%. Both corrected, with the original kept for the un-accelerated case.

⚠ Worth noting for future sizing: fe_sqrt's flat self-time is 0.30%. A flat profile hides this lever 20x, because the ~270 chain operations are attributed to the coprocessor wrappers. Size delegating functions cumulatively.

⛔ A silent no-op the first build walked straight into

The first build exported hazync_lift_x_hint from Rust and never referenced it: only the cargo feature was wired, and nothing defined -DHAZYNC_LIFTX_HINT for the secp build, so the #ifdef block never compiled. It produced a moved METHOD_ID and a clean digest and would have measured zero gain. Caught by a binary symbol assertion, not by any test.

Measured

+6.31%   3,583,757,161 -> 3,357,576,338 cycles   (3.836x -> 4.095x vs stock)
digest   4fb3e3c5...4656d   PASS
hints    6,897 hits / 134 misses   (98.1%)

Predicted ≤6.17% from the cumulative profile, measured 6.31%.

The approach in this PR is sound and the design reasoning in it is correct — advice-and-verify, with libsecp's own fe_sqr/fe_equal deciding, falling back to the real sqrt on a missing or wrong hint. Only the mechanics needed repair. Renumbered to patches/0013 since 0006 is the Schnorr patch on the target branch.

@defenwycke defenwycke closed this Aug 31, 2026
@defenwycke
defenwycke deleted the feat/liftx-hint branch September 1, 2026 06:46
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