Skip to content

perf(ecsm): echo yR and yG, halving the ecalls per ecrecover - #941

Draft
diegokingston wants to merge 2 commits into
mainfrom
perf/ecsm-echo-yg
Draft

perf(ecsm): echo yR and yG, halving the ecalls per ecrecover#941
diegokingston wants to merge 2 commits into
mainfrom
perf/ecsm-echo-yg

Conversation

@diegokingston

Copy link
Copy Markdown
Collaborator

ecrecover needs the full public key (the address is keccak(X‖Y)), but the ECSM ecall returned only xR. With an x-only k·P oracle that costs FOUR ecalls: one x(k·P) query carries no information about the sign of y, since x(k·P) = x(k·(−P)), so each of the two lincomb terms needs a second query at a shifted scalar to pin it (solve_y). Hinting does not help — verifying a sign costs exactly the scalar multiplication it would save.

The chip already computes yR (it arrives on the ECDAS bus) and witnesses yG (the yG convolution proves it). Writing both back drops ecrecover to two ecalls, so ~1533 ECDAS rows per signature become ~768 (measured over 2000 random scalars: 383 rows per ecall).

yG is echoed, not consumed. The chip may witness EITHER root of xG — the AIR binds only yG² ≡ xG³ + b — so yR alone would be ambiguous: it is ±y(k·P) for the caller's own point. Returning the root used lets the guest resolve it with one comparison, which keeps the root a free choice exactly as spec/ecsm.typ's "Two options for y_G" aside argues, while still handing back a usable y. The alternative (feeding yG IN, as PR #879 does) has to pin the root by a memory read instead, which invalidates that aside, widens the input ABI to 64 bytes and adds an on-curve validation path to the executor.

Costs no column: ECSM stays at 667, gaining 8 bus interactions (579 -> 587), i.e. +4 LogUp aux columns. The guest's yR < p check moves from the AIR to the caller's FieldElement::from_bytes, which is free (a CtOption that rejects >= p) and is also what pins yG to a true root: p is odd, so y and p − y differ in parity, but y + p — a second 256-bit representative when y < 2^256 − p ≈ 2^32, and constructible by choosing r — carries the opposite parity.

Guest side: solve_y and scalar_near_edge are gone, three batched inversions become one, and k = 1 / k = N−1 stop being degenerate. Operand buffers are now 8-byte aligned so the twelve doubleword accesses take MEMW_A (29 columns + 1 range check) instead of the general path (49 + 8) — the same fix get_hint already carries.

Executor: the output buffer is 96 bytes, so its address bound moves from +31 to +95. Reads stay at T and T+1 and writes at T+2 (xR) and T+3 (yR, yG), the free fourth sub-timestamp, so aliasing the output over either input keeps every per-address chain monotone.

Verified locally: ECSM AIR constraints hold on generated traces (including k = N−1 and the forged-row rejections), the executor writes and bounds all 96 bytes, and the guest reconstruction matches ProjectivePoint::lincomb under BOTH witnessed roots (an implementation without the fix-up passes the even-root test and fails the odd-root one). The prover suite is unchanged at 390 passed / 167 failed, byte-identical to main: the failures are missing guest ELF artifacts, which this machine cannot build (Apple clang has no RISC-V target). The end-to-end ELF tests and the row-count measurement still need CI / the server.

spec/main needs the matching edit: yR and yG become outputs, the write_xR group gains two MEMW groups, and the "Two options for y_G" aside moves from "only x is output" to "the guest resolves the root from the echoed yG".

ecrecover needs the full public key (the address is keccak(X‖Y)), but the ECSM
ecall returned only xR. With an x-only k·P oracle that costs FOUR ecalls: one
x(k·P) query carries no information about the sign of y, since x(k·P) = x(k·(−P)),
so each of the two lincomb terms needs a second query at a shifted scalar to pin
it (`solve_y`). Hinting does not help — verifying a sign costs exactly the scalar
multiplication it would save.

The chip already computes yR (it arrives on the ECDAS bus) and witnesses yG (the
yG convolution proves it). Writing both back drops ecrecover to two ecalls, so
~1533 ECDAS rows per signature become ~768 (measured over 2000 random scalars:
383 rows per ecall).

yG is echoed, not consumed. The chip may witness EITHER root of xG — the AIR binds
only yG² ≡ xG³ + b — so yR alone would be ambiguous: it is ±y(k·P) for the caller's
own point. Returning the root used lets the guest resolve it with one comparison,
which keeps the root a free choice exactly as spec/ecsm.typ's "Two options for y_G"
aside argues, while still handing back a usable y. The alternative (feeding yG IN,
as PR #879 does) has to pin the root by a memory read instead, which invalidates
that aside, widens the input ABI to 64 bytes and adds an on-curve validation path
to the executor.

Costs no column: ECSM stays at 667, gaining 8 bus interactions (579 -> 587), i.e.
+4 LogUp aux columns. The guest's yR < p check moves from the AIR to the caller's
FieldElement::from_bytes, which is free (a CtOption that rejects >= p) and is also
what pins yG to a true root: p is odd, so y and p − y differ in parity, but y + p —
a second 256-bit representative when y < 2^256 − p ≈ 2^32, and constructible by
choosing r — carries the opposite parity.

Guest side: solve_y and scalar_near_edge are gone, three batched inversions become
one, and k = 1 / k = N−1 stop being degenerate. Operand buffers are now 8-byte
aligned so the twelve doubleword accesses take MEMW_A (29 columns + 1 range check)
instead of the general path (49 + 8) — the same fix get_hint already carries.

Executor: the output buffer is 96 bytes, so its address bound moves from +31 to +95.
Reads stay at T and T+1 and writes at T+2 (xR) and T+3 (yR, yG), the free fourth
sub-timestamp, so aliasing the output over either input keeps every per-address
chain monotone.

Verified locally: ECSM AIR constraints hold on generated traces (including
k = N−1 and the forged-row rejections), the executor writes and bounds all 96
bytes, and the guest reconstruction matches ProjectivePoint::lincomb under BOTH
witnessed roots (an implementation without the fix-up passes the even-root test
and fails the odd-root one). The prover suite is unchanged at 390 passed / 167
failed, byte-identical to main: the failures are missing guest ELF artifacts,
which this machine cannot build (Apple clang has no RISC-V target). The
end-to-end ELF tests and the row-count measurement still need CI / the server.

spec/main needs the matching edit: yR and yG become outputs, the write_xR group
gains two MEMW groups, and the "Two options for y_G" aside moves from "only x is
output" to "the guest resolves the root from the echoed yG".
@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 2.1 ± 0.1 2.1 2.3 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 85.9 ± 0.9 84.1 87.2 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 99.0 ± 2.5 96.6 103.1 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 71.1 ± 0.8 69.8 72.2 1.00

@github-actions

Copy link
Copy Markdown

Benchmark — real block (ethrex_mainnet_25368371.bin) (median of 3)

continuations · epoch 2^22 · 10 epochs

Metric main PR Δ
Peak heap 47973 MB 47579 MB -394 MB (-0.8%) ⚪
Prove time 136.340s 134.966s -1.374s (-1.0%) ⚪

✅ No significant change.

Prove-time spread 0.5% (134.997s / 134.966s / 134.331s)

Commit: f714356 · Baseline: cached · Runner: self-hosted bench

`make lint` (the required check) runs `cargo fmt --check --all` and four clippy
passes with `-D warnings`. The new `run_ecsm_full` helper tripped both:
clippy::type_complexity on its `Result<([u8; 32], [u8; 32], [u8; 32]), _>` return,
and then rustfmt on the one-line form the fix produced. Reuse the `ecsm::EcsmOutput`
alias the crate already exports for exactly this shape, and re-run rustfmt.

`make lint` now exits 0.

Also type-checked the riscv64-gated `ecsm_oracle` body on host with a stub ecall
(that code is behind `#[cfg(target_arch = "riscv64")]`, so neither `make lint` nor
the host tests ever compile it): it builds, both `Align8` buffers come out 8-aligned,
and the three 32-byte chunks are extracted in [xR ‖ yR ‖ yG] order. `commit` takes
`&[u8]`, so the guest program's `commit(&out.0[..32])` is well-typed.

`make test-ethrex-crypto` runs both profiles deliberately (k256 swaps its
FieldElement implementation and only asserts magnitudes in debug); 26/26 in each.
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