perf(ecsm): echo yR and yG, halving the ecalls per ecrecover - #941
Draft
diegokingston wants to merge 2 commits into
Draft
perf(ecsm): echo yR and yG, halving the ecalls per ecrecover#941diegokingston wants to merge 2 commits into
diegokingston wants to merge 2 commits into
Conversation
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".
Collaborator
Author
|
/bench |
|
Benchmark Results for modified programs 🚀
|
Benchmark — real block (
|
| 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.
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.
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".