Skip to content

Commit 0f66dd2

Browse files
authored
Merge branch 'main' into feat/continuation-precomputed-commitments
2 parents 60fdb39 + 68a120a commit 0f66dd2

26 files changed

Lines changed: 2497 additions & 191 deletions

File tree

.github/workflows/pr_main.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ jobs:
131131
- name: Run CLI tests
132132
run: cargo test -p cli
133133

134+
- name: Run syscalls host tests (keccak differential vs sha3)
135+
run: make test-syscalls
136+
134137
# "Test" is a required check — keep this name to avoid branch protection changes.
135138
# This gate job passes only when CLI, executor, disk-spill, and prover tests succeed.
136139
test:

Makefile

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.PHONY: deps deps-linux deps-macos compile-programs-asm compile-programs-rust compile-bench \
22
compile-programs compile-recursion-elfs clean-asm clean-rust clean-bench clean-shared \
33
clean-recursion-elfs clean test test-asm \
4-
test-rust test-ethrex test-executor test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \
4+
test-rust test-ethrex test-executor test-syscalls test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \
55
test-fast test-prover test-prover-all test-prover-debug test-disk-spill test-math-cuda test-cuda-integration test-cuda-fallback \
66
test-prover-cuda test-prover-comprehensive-cuda \
77
bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \
@@ -293,7 +293,16 @@ update-ethrex-fixture-checksums:
293293
check-ethrex-fixture-checksums:
294294
python3 tooling/ethrex-fixtures/update_readme_checksums.py --check
295295

296-
test: compile-programs
296+
# The syscalls crate is excluded from the workspace (riscv-only bare-metal
297+
# entrypoints/allocator that assemble only for the guest target — see the root
298+
# Cargo.toml exclude), so the root `cargo test` never reaches its host
299+
# differential tests (the keccak sponge vs sha3 reference). Run them explicitly
300+
# in the crate dir; wired into `test` below and run as a dedicated step
301+
# in CI's cli-test job (pr_main.yaml).
302+
test-syscalls:
303+
cd syscalls && cargo test
304+
305+
test: compile-programs test-syscalls
297306
cargo test
298307

299308
# === Quick test shortcuts ===

crypto/crypto/src/hash/platform_keccak.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ mod imp {
1111
};
1212
use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256;
1313

14+
// INVARIANT (load-bearing): this adapter must remain a PURE PASSTHROUGH of
15+
// `SyscallKeccak256`. The TypeId specializations in
16+
// crypto/crypto/src/merkle_tree/backends/field_element_vector.rs bypass it
17+
// and drive the syscall sponge directly, on the assumption that both paths
18+
// hash identically. Adding ANY behavior here (a domain prefix, extra
19+
// absorption, a different reset policy) silently desyncs the specialized
20+
// branches from the generic path — and the failure surfaces as in-guest
21+
// proof rejection, not as a host test failure.
22+
1423
#[derive(Clone, Default)]
1524
pub struct PlatformKeccak256(SyscallKeccak256);
1625

crypto/crypto/src/merkle_tree/backends/field_element_vector.rs

Lines changed: 98 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,88 @@ use math::{
99
traits::AsBytes,
1010
};
1111

12+
#[cfg(target_arch = "riscv64")]
13+
use crate::hash::platform_keccak::PlatformKeccak256;
14+
#[cfg(target_arch = "riscv64")]
15+
use core::any::TypeId;
16+
#[cfg(target_arch = "riscv64")]
17+
use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256;
18+
19+
/// Absorb `feed`'s byte stream into a fresh `D` and return the digest as a
20+
/// fixed `[u8; NUM_BYTES]`.
21+
///
22+
/// On the riscv64 guest, when `D` is the platform keccak digest and the node
23+
/// is 32 bytes, this drives the syscall sponge directly and squeezes straight
24+
/// into the result array. That skips the `Digest::finalize` blanket, which
25+
/// allocates a zeroed `GenericArray`, has the adapter fill a local `[u8; 32]`
26+
/// and copy it into that `Output`, then leaves the caller to copy the `Output`
27+
/// once more into its own array — two 32-byte memcpys plus a 32-byte memset of
28+
/// pure plumbing around the one permutation. Byte-identical to the generic
29+
/// path; every other digest / node size (and the entire host build) takes the
30+
/// generic path unchanged.
31+
///
32+
/// DO NOT replace this `TypeId` dispatch with a generic `Digest::finalize_into`
33+
/// fix "at the adapter altitude" — that exact refactor was implemented and
34+
/// MEASURED SLOWER on the guest across every formulation tried (best:
35+
/// +60k min = +0.14%, +1.25M blowup8 = +0.48%), including `#[inline]`
36+
/// adapters and a check-free `AsMut` output conversion. The residual is
37+
/// intrinsic: `FixedOutput::finalize_into` moves the 208-byte sponge by value
38+
/// through the newtype + trait layer into a non-inlined cross-crate call, and
39+
/// without LTO the placement isn't elided; the direct branch below builds the
40+
/// sponge in place at the call's ABI slot. Deleting the dispatch also cannot
41+
/// remove the `'static` bounds — `hash_new_parent_bytes` needs them regardless.
42+
#[inline]
43+
fn hash_streamed<D: Digest + 'static, const NUM_BYTES: usize>(
44+
feed: impl Fn(&mut dyn FnMut(&[u8])),
45+
) -> [u8; NUM_BYTES] {
46+
#[cfg(target_arch = "riscv64")]
47+
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
48+
let mut hasher = SyscallKeccak256::new();
49+
feed(&mut |bytes| hasher.update(bytes));
50+
let mut result = [0u8; NUM_BYTES];
51+
// NUM_BYTES == 32 in this branch, so the slice is exactly a [u8; 32].
52+
let out: &mut [u8; 32] = (&mut result[..]).try_into().unwrap();
53+
hasher.finalize(out);
54+
return result;
55+
}
56+
57+
let mut hasher = D::new();
58+
feed(&mut |bytes| hasher.update(bytes));
59+
let mut result_hash = [0_u8; NUM_BYTES];
60+
result_hash.copy_from_slice(&hasher.finalize());
61+
result_hash
62+
}
63+
64+
/// Hash a Merkle parent — always exactly two concatenated 32-byte nodes.
65+
///
66+
/// On the riscv64 guest, when `D` is the platform keccak digest and nodes are
67+
/// 32 bytes, this is one fixed-shape 64-byte compression ([`keccak256_pair`]):
68+
/// a single permutation with the input lanes and padding written straight into
69+
/// the state, skipping the incremental sponge's per-byte absorb, running
70+
/// offset, and separate padding pass. Byte-identical to streaming both nodes
71+
/// through the digest; every other digest / node size (and the host build)
72+
/// takes the generic streaming-and-finalize path unchanged.
73+
#[inline]
74+
fn hash_new_parent_bytes<D: Digest + 'static, const NUM_BYTES: usize>(
75+
left: &[u8; NUM_BYTES],
76+
right: &[u8; NUM_BYTES],
77+
) -> [u8; NUM_BYTES] {
78+
#[cfg(target_arch = "riscv64")]
79+
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
80+
let l: &[u8; 32] = left[..].try_into().unwrap();
81+
let r: &[u8; 32] = right[..].try_into().unwrap();
82+
let hash = lambda_vm_syscalls::keccak::keccak256_pair(l, r);
83+
let mut result = [0u8; NUM_BYTES];
84+
result.copy_from_slice(&hash);
85+
return result;
86+
}
87+
88+
hash_streamed::<D, NUM_BYTES>(|sink| {
89+
sink(left);
90+
sink(right);
91+
})
92+
}
93+
1294
/// A backend for Merkle trees that uses fixed-size pairs of field elements.
1395
/// This is more efficient than `FieldElementVectorBackend` when the batch size is always 2,
1496
/// as it avoids Vec allocation overhead.
@@ -27,7 +109,7 @@ impl<F, D: Digest, const NUM_BYTES: usize> Default for FieldElementPairBackend<F
27109
}
28110
}
29111

30-
impl<F, D: Digest, const NUM_BYTES: usize> IsMerkleTreeBackend
112+
impl<F, D: Digest + 'static, const NUM_BYTES: usize> IsMerkleTreeBackend
31113
for FieldElementPairBackend<F, D, NUM_BYTES>
32114
where
33115
F: IsField,
@@ -38,21 +120,14 @@ where
38120
type Data = [FieldElement<F>; 2];
39121

40122
fn hash_data(input: &[FieldElement<F>; 2]) -> [u8; NUM_BYTES] {
41-
let mut hasher = D::new();
42-
input[0].stream_bytes(&mut |b| hasher.update(b));
43-
input[1].stream_bytes(&mut |b| hasher.update(b));
44-
let mut result_hash = [0_u8; NUM_BYTES];
45-
result_hash.copy_from_slice(&hasher.finalize());
46-
result_hash
123+
hash_streamed::<D, NUM_BYTES>(|sink| {
124+
input[0].stream_bytes(sink);
125+
input[1].stream_bytes(sink);
126+
})
47127
}
48128

49129
fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] {
50-
let mut hasher = D::new();
51-
hasher.update(left);
52-
hasher.update(right);
53-
let mut result_hash = [0_u8; NUM_BYTES];
54-
result_hash.copy_from_slice(&hasher.finalize());
55-
result_hash
130+
hash_new_parent_bytes::<D, NUM_BYTES>(left, right)
56131
}
57132
}
58133

@@ -71,7 +146,7 @@ impl<F, D: Digest, const NUM_BYTES: usize> Default for FieldElementVectorBackend
71146
}
72147
}
73148

74-
impl<F, D: Digest, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
149+
impl<F, D: Digest + 'static, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
75150
where
76151
[u8; NUM_BYTES]: From<Output<D>>,
77152
{
@@ -80,15 +155,11 @@ where
80155
/// once, avoiding per-element allocations while staying consistent with the
81156
/// backend's hash function.
82157
pub fn hash_bytes(data: &[u8]) -> [u8; NUM_BYTES] {
83-
let mut hasher = D::new();
84-
hasher.update(data);
85-
let mut result = [0u8; NUM_BYTES];
86-
result.copy_from_slice(&hasher.finalize());
87-
result
158+
hash_streamed::<D, NUM_BYTES>(|sink| sink(data))
88159
}
89160
}
90161

91-
impl<F, D: Digest, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
162+
impl<F, D: Digest + 'static, const NUM_BYTES: usize> FieldElementVectorBackend<F, D, NUM_BYTES>
92163
where
93164
F: IsField,
94165
FieldElement<F>: AsBytes,
@@ -100,17 +171,15 @@ where
100171
/// `hash_data(&[a, b].concat())`: the sponge absorbs the same element bytes
101172
/// in the same order, just without the intermediate `Vec`.
102173
pub fn hash_data_from_slices(a: &[FieldElement<F>], b: &[FieldElement<F>]) -> [u8; NUM_BYTES] {
103-
let mut hasher = D::new();
104-
for element in a.iter().chain(b.iter()) {
105-
element.stream_bytes(&mut |bytes| hasher.update(bytes));
106-
}
107-
let mut result_hash = [0_u8; NUM_BYTES];
108-
result_hash.copy_from_slice(&hasher.finalize());
109-
result_hash
174+
hash_streamed::<D, NUM_BYTES>(|sink| {
175+
for element in a.iter().chain(b.iter()) {
176+
element.stream_bytes(sink);
177+
}
178+
})
110179
}
111180
}
112181

113-
impl<F, D: Digest, const NUM_BYTES: usize> IsMerkleTreeBackend
182+
impl<F, D: Digest + 'static, const NUM_BYTES: usize> IsMerkleTreeBackend
114183
for FieldElementVectorBackend<F, D, NUM_BYTES>
115184
where
116185
F: IsField,
@@ -129,12 +198,7 @@ where
129198
}
130199

131200
fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] {
132-
let mut hasher = D::new();
133-
hasher.update(left);
134-
hasher.update(right);
135-
let mut result_hash = [0_u8; NUM_BYTES];
136-
result_hash.copy_from_slice(&hasher.finalize());
137-
result_hash
201+
hash_new_parent_bytes::<D, NUM_BYTES>(left, right)
138202
}
139203
}
140204

crypto/math-cuda/kernels/logup.cu

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ extern "C" __global__ void logup_term_ext3(
115115
// Accumulated column (K4): running sum of the term columns, on device.
116116
// row_sum[i] = sum over all term columns of term[col][i]
117117
// S = inclusive prefix scan of row_sum ; L = S[n-1] ; offset = L / N
118-
// acc[i] = S[i] - (i+1) * offset (matches build_accumulated_column_from_terms)
118+
// acc[i] = S[i-1] - i * offset (acc[0]=0) (matches build_accumulated_column_from_terms)
119119
// Additive 3-phase Hillis-Steele scan (mirrors inverse.cu, add not mul).
120120
// ===========================================================================
121121

@@ -193,7 +193,10 @@ extern "C" __global__ void logup_apply_offsets_add_ext3(
193193
scan_inout[o + 2] = v.c;
194194
}
195195

196-
// acc[i] = scan[i] - (i+1) * (L * inv_N), L = scan[n-1]. inv_N is ext3 (1/N).
196+
// Forward accumulation (matches build_accumulated_column_from_terms):
197+
// acc[i] = scan_exclusive[i] - i * (L * inv_N), L = scan[n-1], inv_N = 1/N.
198+
// scan is the INCLUSIVE prefix scan, so scan_exclusive[i] = scan[i-1] and
199+
// acc[0] = 0. This is the exclusive-scan analogue of the old inclusive form.
197200
extern "C" __global__ void logup_finalize_accum_ext3(
198201
const uint64_t *__restrict__ scan, uint64_t n, uint64_t inv0, uint64_t inv1,
199202
uint64_t inv2, uint64_t *__restrict__ acc) {
@@ -203,8 +206,11 @@ extern "C" __global__ void logup_finalize_accum_ext3(
203206
uint64_t lo = (n - 1) * 3;
204207
Fe3 L = make(scan[lo], scan[lo + 1], scan[lo + 2]);
205208
Fe3 offset = mul(L, make(inv0, inv1, inv2));
206-
Fe3 s = make(scan[i * 3], scan[i * 3 + 1], scan[i * 3 + 2]);
207-
Fe3 a = sub(s, mul_base(offset, i + 1));
209+
// Exclusive prefix: row 0 has no predecessor, so acc[0] = 0.
210+
Fe3 s = (i == 0) ? zero()
211+
: make(scan[(i - 1) * 3], scan[(i - 1) * 3 + 1],
212+
scan[(i - 1) * 3 + 2]);
213+
Fe3 a = sub(s, mul_base(offset, i));
208214
acc[i * 3] = a.a;
209215
acc[i * 3 + 1] = a.b;
210216
acc[i * 3 + 2] = a.c;

crypto/math/src/field/extensions_goldilocks.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -555,10 +555,14 @@ impl AsBytes for FieldElement<Degree3GoldilocksExtensionField> {
555555
self.to_bytes_be()
556556
}
557557

558-
// One sink call over a stack buffer instead of three (one per limb): each
559-
// sink call lands as its own `Digest::update` on the guest, and dyn dispatch
560-
// here is fully devirtualized by the #[inline(always)] chain, so call count
561-
// — not indirection — is the cost being cut.
558+
// Same 24 bytes as `as_bytes`, staged in a stack buffer so the guest skips
559+
// the per-element `Vec`; `#[inline(always)]` is what lets the `dyn` sink
560+
// devirtualize at the call site. Emitting them in one call rather than one
561+
// per limb keeps it to a single `Digest::update`.
562+
//
563+
// The layout is load-bearing beyond this crate: `math-cuda`'s
564+
// `keccak_leaves_ext3` kernel reads components in order 0,1,2 to match
565+
// `write_bytes_be`, and CPU/GPU leaf parity depends on the two agreeing.
562566
#[inline(always)]
563567
fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) {
564568
let mut buf = [0u8; 24];

crypto/math/src/traits.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ pub trait AsBytes {
4242

4343
/// Streams the byte representation to `sink` without heap-allocating a `Vec`.
4444
/// Default falls back to `as_bytes`; override for zero-allocation hashing/transcript hot paths.
45+
///
46+
/// An override must stream exactly the bytes `as_bytes` would return, in
47+
/// order; splitting them across several `sink` calls is fine, but the
48+
/// concatenation must be identical. Merkle leaf hashes and the Fiat-Shamir
49+
/// transcript take their input through here, so an override that disagrees
50+
/// with `as_bytes` silently changes commitments and challenges rather than
51+
/// failing to compile. `math/tests/stream_bytes_parity.rs` pins this for the
52+
/// Goldilocks fields.
4553
fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) {
4654
sink(&self.as_bytes());
4755
}

0 commit comments

Comments
 (0)