Skip to content

Perf/ecsm affine selector - #879

Open
jotabulacios wants to merge 12 commits into
feat/hint-ecallfrom
perf/ecsm-affine-selector
Open

Perf/ecsm affine selector#879
jotabulacios wants to merge 12 commits into
feat/hint-ecallfrom
perf/ecsm-affine-selector

Conversation

@jotabulacios

@jotabulacios jotabulacios commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an affine variant of the ECSM ecall (ECSM_AFFINE_SYSCALL_NUMBER = u64::MAX - 11): input
xG‖yG (64 B), output xR‖yR (64 B), against the x-only variant's 32 B in/out. An IS_AFFINE
selector column lets one prover serve both ABIs, so existing x-only consumers (ecsm.elf, bench,
ASM test_ecsm*) keep working unchanged.

With x-only output the guest could not learn y(k·P), so it recovered it from a second query
x((k+1)·P) plus the chord-addition law — two accelerator calls and a batch of field arithmetic
per point. ethrex's ecrecover now takes y from the chip: two ECSM queries per 2-term linear
combination instead of four, and solve_y is gone.

Details

  • The ecall (executor/src/vm/instruction/execution.rs): ECSM_AFFINE_SYSCALL_NUMBER gets
    its own handler branch (scalar_mul_xy_with_y). Both operand buffers are 64 B, so the
    address-limb guard spans offset 63 for xG/xR (31 for the scalar) and the xG/k
    disjointness guard widens to 64 B. Validates 0 < k < N, xG < p, yG < p and that
    (xG, yG) is on the curve.
  • IS_AFFINE selector (prover/src/tables/ecsm.rs, 667 → 684 columns, 413 → 423
    constraints): the yG-read (4 dwords at addr_xG + 32, ts) and yR-write (4 dwords at
    addr_xR + 32, ts+3) MEMW buses fire with mult = IS_AFFINE, so they are inert on x-only rows
    and on padding. The x-only path is untouched: yG stays the canonical even lift and yR is
    witnessed only for ECDAS.
  • yR < p (OverflowKind::YrLtP, +16 halfword columns and its carry chain): publishing yR
    is what makes its representation observable, and the byte range checks only bound it below
    2^256. xR was already forced canonical by XrLtP; this gives the other half of the output
    the same treatment.
  • Guest side: ecsm_mul_affine in syscalls/src/syscalls.rs, and lincomb2_with_oracle in
    crypto/ethrex-crypto/src/lib.rs now does two oracle queries instead of four and drops
    solve_y entirely. It still needs one field inverse for the final affine addition.
  • New test guest ecsm_affine, plus a forged-yR rejection test.

Measured (ethrex bench_20, bench server, baseline = the hint branch this sits on)

3 interleaved pairs, each side with its own prover and its own guest ELF, since this touches
both. All proofs verify.

Metric base with affine Delta
Prove (median) 17.217 s 16.589 s −3.6%
Peak heap 33,345 MB 31,808 MB −4.6%
Guest cycles 5,087,142 4,906,209 −3.6%
ECSM calls 80 40 −50%
Prove, growth series base with affine Delta
4 transfers 9.458 s 8.847 s −6.5%
8 10.892 s 10.389 s −4.6%
12 13.267 s 12.674 s −4.5%
16 15.962 s 14.739 s −7.7%
20 17.450 s 16.618 s −4.8%

Halving the accelerator calls is the structural part; prove time moves less because ECSM/ECDAS is
a fraction of the trace. An earlier measurement of this work reported −10.2%, on a baseline that
sat 14,466 cycles above 8 × 524,288 and therefore carried a ninth CPU chunk the affine side
dropped — a boundary artifact, not the change. Pairs are interleaved rather than run as
sequential blocks, because cross-session drift on that machine exceeds the effect being measured.

Soundness

Returning y makes two things observable that were not:

  • Input parity. The AIR constrains yG only through yG² ≡ xG³ + b, which holds for both
    +yG and −yG. On the x-only path that freedom is invisible (x(k·P) = x(k·(−P))), but once
    yR is published a witness could pick −yG and get a correctly-computed multiple of a
    different point — indistinguishable to both the AIR (an on-curve check passes either way) and
    the guest (it cannot know the parity of k·P without doing the multiplication it delegated).
    The yG MEMW read closes it by pinning the witnessed column to the caller's own buffer.
  • Output canonicality. The quotient columns absorb a multiple of p, so without YrLtP a
    witness could publish yR + p whenever yR < 2^256 − p (~2^32), and such points are
    constructible: 3 | p−1 makes cubing 3-to-1, so a small target y has a cube-root preimage
    about a third of the time.

IS_AFFINE itself is pinned by the Ecall receiver, whose syscall word is
xonly + IS_AFFINE·(affine − xonly); the CPU sends the real a7, so a row claiming the wrong
mode leaves the bus unbalanced. IS_BIT(IS_AFFINE) and IS_AFFINE·(1 − µ) = 0 keep it a bit
that is zero on padding. The written yR inherits its byte range checks through the ECDAS bus,
which carries the coordinate byte by byte and range-checks its own columns.

Removing the reconstruction also retires an argument rather than adding one: the x-only path had
to establish that the wrong sign of ya was excluded by the scalar guards, which is what made
k = 1 and k = N−1 degenerate. With y supplied by the chip those scalars are ordinary —
secp256k1 has cofactor 1 and prime N, so k·P ≠ O for every k ∈ (0, N).

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 3.6 ± 0.1 3.5 3.8 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 115.4 ± 3.6 109.7 122.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 131.4 ± 2.8 126.4 135.4 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 91.8 ± 0.8 91.0 93.8 1.00

@diegokingston

Copy link
Copy Markdown
Collaborator

/bench

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Benchmark — ethrex 20 transfers (median of 3)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 50217 MB 29538 MB -20679 MB (-41.2%) 🟢
Prove time 24.695s 16.139s -8.556s (-34.6%) 🟢

🎉 Improvement detected — heap or time decreased by more than 5%.

✅ Low variance (time: 2.2%, heap: 1.4%)

Commit: 9ce5c1b · Baseline: cached · Runner: self-hosted bench

…affine-selector

# Conflicts:
#	crypto/ethrex-crypto/src/tests/ecsm_tests.rs
#	executor/src/vm/instruction/execution.rs
#	prover/src/tables/cpu.rs
#	syscalls/src/syscalls.rs
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios
jotabulacios marked this pull request as ready for review July 30, 2026 17:19
Brings the HINT syscall renumbering (u64::MAX - 30, off the FEXT_FMA number), the
field_inv software fallback and its test seam, the HINT table GPU parity check and
the ethrex-crypto host tests in CI.

crypto/ethrex-crypto/src/lib.rs conflicted: the base inserted field_inv_with_oracle
exactly where this branch had rewritten lincomb2_with_oracle's doc. Kept both, with
the affine wording — the base's "from four x-only oracle queries" describes the
reconstruction this branch removes.
The affine ecall rejected any k within 64 bytes of the input point, but the point is
64 bytes and the scalar 32, so a scalar sitting directly below the point is disjoint at a
distance of 32 and was rejected anyway. Both callers build the two buffers as separate
stack arrays, so whether the ecall worked at all depended on which one the guest compiler
laid out first: a rustc or LTO change could turn ecrecover into a hard EcsmOperandOverlap,
which has no software fallback. The guard now tests the two byte ranges, which is what
trace provability actually requires — xG‖yG is read at T and k at T+1, so they only need
to not touch. The doc comment claiming the distance assumption held by construction was
true only for the x-only path, where both operands are 32 bytes.

Adds executor coverage for the boundary case that used to fail, for a non-canonical yG
and for the 64-byte address-limb span.
The column-count comment in the ECSM chip still said 668 after yR_sub_p landed (684), and
scalar_mul_affine's doc claimed its input is the even-y lift of xG, which is only true on
the x-only path — the affine path passes the caller's own point, which is the whole point
of the function. Drops the "PoC" labels left in production paths and puts the new syscall
variant in discriminant order.

The new guest's Cargo.lock was generated on a tree carrying the dlmalloc allocator, so it
listed dlmalloc and critical-section as lambda-vm-syscalls dependencies that the crate
does not declare; the guest build passes no --locked, so it silently rewrote the file on
every run and dirtied the tree after every make compile-programs.

Also formats crypto/ethrex-crypto, which no CI job checks because it is not a workspace
member, and sizes the ECSM memw op vector for affine rows, which push eight more ops.
The affine selector and yR canonicality had no direct tests: op_for hardcoded
is_affine = false, so IS_BIT(IS_AFFINE) and AffineZeroOnPadding were only reached through
the end-to-end ELF test, and nothing exercised OverflowRequired(YrLtP) at all, even though
it is what keeps the published yR canonical.

Adds a mixed affine / x-only trace, the isolated selector checks, a yR = p case mirroring
the existing xG one, and an end-to-end test that clears IS_AFFINE on the affine row. That
last one confirms the Ecall-bus anchor empirically rather than by argument: the receiver's
syscall word is xonly + IS_AFFINE·(affine − xonly) and the CPU sends the guest's real a7,
so a row claiming the wrong variant leaves that bus and the two it gates unbalanced.
@jotabulacios
jotabulacios changed the base branch from main to feat/hint-ecall July 31, 2026 19:07
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

No actionable issues found in the specified PR diff. Static review only; tests/builds were not run as instructed.

@github-actions

Copy link
Copy Markdown

AI Review

PR #879 · 17 changed files

Findings

Status Sev Location Finding Found by
confirmed low crypto/ecsm/src/lib.rs:76 EcsmError Display messages don't cover the affine path kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low crypto/ecsm/src/lib.rs:105 Repeated p() allocations in prepare_with_y nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-002: EcsmError Display messages don't cover the affine path
  • Status: confirmed
  • Severity: low
  • Location: crypto/ecsm/src/lib.rs:76
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The Display impl for EcsmError::NotOnCurve says "ECSM xG is not a valid curve x-coordinate" and CoordinateOutOfRange says "ECSM xG must be < p". With the new affine entry point (prepare_with_y / scalar_mul_xy_with_y), NotOnCurve can also mean the full point (xG, yG) is not on the curve, and CoordinateOutOfRange can mean yG >= p. The messages are now misleading when the affine ecall rejects an input.

Evidence

lib.rs lines 76-85 show the unchanged Display messages. Lines 127-151 add prepare_with_y, which returns EcsmError::NotOnCurve when yG² ≢ xG³+b (mod p) and EcsmError::CoordinateOutOfRange when xg >= p() || yg >= p(). The executor's affine ecall maps these errors back to the guest (execution.rs lines 540-585).

Suggested fix

Update the Display strings to describe both cases, e.g. "ECSM input point is not on the curve" for NotOnCurve and "ECSM xG/yG must be < p" for CoordinateOutOfRange, or split the messages per entry point.

AI-003: Repeated p() allocations in prepare_with_y
  • Status: confirmed
  • Severity: low
  • Location: crypto/ecsm/src/lib.rs:105
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The on-curve check in prepare_with_y calls p() three times, each constructing a new BigUint from P_BYTES.

Evidence

Line 105: let lhs = (&amp;yg * &amp;yg) % p(); let rhs = (&amp;xg * &amp;xg % p() * &amp;xg + BigUint::from(B)) % p(); — p() is invoked three times.

Suggested fix

Cache p() in a local variable: let p = p(); and reuse it.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 0
kimi openrouter/moonshotai/kimi-k2.7-code general success 1
minimax minimax/MiniMax-M3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
moonmath zro/minimax-m3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 5

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 2 4 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Discarded candidates (4) — rejected by the verifier
  • Affine overlap check uses correct exact-range test but comment references old distance bound (executor/src/vm/instruction/execution.rs:538, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The claim itself states the comment is accurate for the new logic and merely contrasts the old distance bound. No actual defect exists — the code is correct and the comment is accurate. This is not a real issue.
  • Missing yG < p check in executor affine path for k=1 case (executor/src/vm/instruction/execution.rs:530, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The comment the finding references (the doc comment on CoordinateOutOfRange at lib.rs lines 70-72) correctly describes the x-only path's k=1 echo issue for xG. The affine path handles yG via prepare_with_y which explicitly checks yG < p. There is no missing yG validation and no need to update the x-only comment — the two paths are separate codeblocks with separate comments. This is an overly nitpicky documentation concern, not a real defect.
  • yR_sub_p column added but not documented in column index comments (prover/src/tables/ecsm.rs:55, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The claim asserts IS_AFFINE and YR_SUB_P are not in the column index comments. They are in fact present in the same column definition block: IS_AFFINE at line 71 with a doc comment ('Mode selector...') and YR_SUB_P at line 73 with a doc comment ('(yR - p) mod 2^256...'). Both are documented — just with /// style instead of // style. The claim is factually incorrect.
  • MEMW bus timestamp sharing for xG and yG reads (prover/src/tables/trace_builder.rs:815, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — This is a speculative theoretical concern with no evidence of an actual problem. The xG and yG read addresses are disjoint (addr_xg vs addr_xg+32), so their order at timestamp T is irrelevant — they don't overlap. The MEMW bus processes all operations at a given timestamp, and with disjoint addresses there is no consistency issue regardless of internal ordering. No real defect exists.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

NotOnCurve and CoordinateOutOfRange both predate the affine ecall and named only
xG: "ECSM xG is not a valid curve x-coordinate" and "ECSM xG must be < p". The
affine path returns the same two variants for a caller-supplied yG that fails
yG² ≡ xG³ + b or is >= p, so the messages pointed at the wrong operand. Rewords
them and the variant docs to cover both entry points.

Also hoists the modulus in prepare_with_y: it was rebuilt from P_BYTES five times
per call (two range checks plus three reductions in the on-curve test). One
BigUint either way against the scalar multiplication that follows, so this is for
the reader, not the clock.
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

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.

2 participants